Compare commits
38 Commits
baa216d62c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b917321cf | |||
| 0bec3c574a | |||
| 268889ead4 | |||
| 9d4865306c | |||
| 65c7da4852 | |||
| 81173d7ab5 | |||
| 6c4d0b91ca | |||
| 0c9b14a54b | |||
| 56016a6124 | |||
| 01ffe79bbe | |||
| 0f997b639f | |||
| 726aafef74 | |||
| 31e0b39794 | |||
| a653c77959 | |||
| 35e9f7e812 | |||
| 4a2f6cf0fd | |||
| c1e4e8e404 | |||
| bc72a5b1cb | |||
| 7b925c89bd | |||
| b391338d5b | |||
| 2d0611c2c9 | |||
|
|
4d30bacc53 | ||
|
|
fb82dc68d7 | ||
| 4ddd24fd72 | |||
| c7df40ac26 | |||
| 57a206d7b3 | |||
| 2521c4e998 | |||
| de0ddac65d | |||
| e5294ec712 | |||
|
|
a10ef7eb70 | ||
| 8506fd17ef | |||
| d2097d8930 | |||
| a804cab431 | |||
| 98b01bf48f | |||
|
|
cb5851ec8c | ||
| bce4787802 | |||
| 540ca5ebad | |||
|
|
a0799c0647 |
19
.env.example
@@ -4,3 +4,22 @@ PORT=3000
|
|||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
HIBP_API_KEY=""
|
HIBP_API_KEY=""
|
||||||
RESEND_API_KEY=""
|
RESEND_API_KEY=""
|
||||||
|
AWS_REGION="us-east-1"
|
||||||
|
|
||||||
|
# Datadog APM Configuration
|
||||||
|
DD_SERVICE="shieldai-api"
|
||||||
|
DD_ENV="development"
|
||||||
|
DD_VERSION="0.1.0"
|
||||||
|
DD_TRACE_ENABLED="true"
|
||||||
|
DD_TRACE_SAMPLE_RATE="1.0"
|
||||||
|
DD_LOGS_INJECTION="true"
|
||||||
|
DD_AGENT_HOST="localhost"
|
||||||
|
DD_AGENT_PORT="8126"
|
||||||
|
DD_API_KEY=""
|
||||||
|
DD_SITE="datadoghq.com"
|
||||||
|
|
||||||
|
# Sentry Error Tracking
|
||||||
|
SENTRY_DSN=""
|
||||||
|
SENTRY_ENVIRONMENT="development"
|
||||||
|
SENTRY_RELEASE="0.1.0"
|
||||||
|
SENTRY_TRACES_SAMPLE_RATE="0.1"
|
||||||
|
|||||||
162
.github/workflows/ci.yml
vendored
@@ -24,11 +24,14 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: "npm"
|
cache: "pnpm"
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: ${{ env.PNPM_VERSION }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: pnpm install --frozen-lockfile
|
||||||
- name: Run linter
|
- name: Run linter
|
||||||
run: npm run lint
|
run: pnpm lint
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
name: Type Check
|
name: Type Check
|
||||||
@@ -39,11 +42,14 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: "npm"
|
cache: "pnpm"
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: ${{ env.PNPM_VERSION }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: pnpm install --frozen-lockfile
|
||||||
- name: Build all packages
|
- name: Build all packages
|
||||||
run: npm run build
|
run: pnpm build
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Test Suite
|
name: Test Suite
|
||||||
@@ -77,15 +83,14 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: "npm"
|
cache: "pnpm"
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: ${{ env.PNPM_VERSION }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: pnpm install --frozen-lockfile
|
||||||
- name: Generate Prisma client
|
|
||||||
run: npx prisma generate --schema=packages/db/prisma/schema.prisma
|
|
||||||
env:
|
|
||||||
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
|
|
||||||
- name: Run tests with coverage
|
- name: Run tests with coverage
|
||||||
run: npm run test:coverage
|
run: pnpm test:coverage
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
|
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
|
||||||
REDIS_URL: "redis://localhost:6379"
|
REDIS_URL: "redis://localhost:6379"
|
||||||
@@ -100,8 +105,9 @@ jobs:
|
|||||||
docker-build:
|
docker-build:
|
||||||
name: Docker Build
|
name: Docker Build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [lint, typecheck]
|
needs: [lint, typecheck, test]
|
||||||
strategy:
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- name: api
|
- name: api
|
||||||
@@ -118,6 +124,8 @@ jobs:
|
|||||||
dockerfile: services/voiceprint/Dockerfile
|
dockerfile: services/voiceprint/Dockerfile
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- name: Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
@@ -127,3 +135,129 @@ jobs:
|
|||||||
tags: shieldai-${{ matrix.name }}:${{ github.sha }}
|
tags: shieldai-${{ matrix.name }}:${{ github.sha }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
security-scan:
|
||||||
|
name: Security Scan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Run pnpm audit
|
||||||
|
run: pnpm audit --prod
|
||||||
|
- name: Trivy filesystem scan
|
||||||
|
uses: aquasecurity/trivy-action@master
|
||||||
|
with:
|
||||||
|
scan-type: fs
|
||||||
|
scan-ref: "."
|
||||||
|
format: table
|
||||||
|
exit-code: 1
|
||||||
|
ignore-unfixed: true
|
||||||
|
severity: CRITICAL,HIGH
|
||||||
|
|
||||||
|
terraform-plan:
|
||||||
|
name: Terraform Plan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint]
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Configure AWS Credentials
|
||||||
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
|
with:
|
||||||
|
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
|
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
aws-region: us-east-1
|
||||||
|
- name: Terraform Format
|
||||||
|
working-directory: infra
|
||||||
|
run: terraform fmt -check -diff
|
||||||
|
- name: Terraform Init
|
||||||
|
working-directory: infra
|
||||||
|
run: terraform init
|
||||||
|
- name: Terraform Validate
|
||||||
|
working-directory: infra
|
||||||
|
run: terraform validate
|
||||||
|
- name: Terraform Plan
|
||||||
|
working-directory: infra
|
||||||
|
run: terraform plan -var-file=environments/staging/terraform.tfvars.example -no-color
|
||||||
|
env:
|
||||||
|
TF_VAR_hibp_api_key: ${{ secrets.HIBP_API_KEY }}
|
||||||
|
TF_VAR_resend_api_key: ${{ secrets.RESEND_API_KEY }}
|
||||||
|
|
||||||
|
load-test:
|
||||||
|
name: Load Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint, typecheck, test, docker-build]
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
environment: staging
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install k6
|
||||||
|
run: |
|
||||||
|
K6_VERSION="v0.50.0"
|
||||||
|
K6_URL="https://github.com/grafana/k6/releases/download/${K6_VERSION}/k6-linux-amd64.tar.gz"
|
||||||
|
K6_SHA256="d950a2408d0be2dc81aef397a7c984a1d84271d7ae94ff7a47d08371904f0800"
|
||||||
|
curl -sSL "${K6_URL}" -o k6.tar.gz
|
||||||
|
echo "${K6_SHA256} k6.tar.gz" | sha256sum --check --strict -
|
||||||
|
tar xzf k6.tar.gz
|
||||||
|
sudo mv k6 /usr/local/bin/
|
||||||
|
k6 version
|
||||||
|
|
||||||
|
- name: Validate required secrets
|
||||||
|
run: |
|
||||||
|
if [ -z "$API_TOKEN" ]; then
|
||||||
|
echo "❌ LOAD_TEST_API_TOKEN secret is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run combined load tests
|
||||||
|
run: |
|
||||||
|
chmod +x scripts/load-test/run-all.sh
|
||||||
|
./scripts/load-test/run-all.sh
|
||||||
|
env:
|
||||||
|
LOAD_TEST_BASE_URL: ${{ secrets.LOAD_TEST_BASE_URL || 'http://localhost:3000' }}
|
||||||
|
API_TOKEN: ${{ secrets.LOAD_TEST_API_TOKEN }}
|
||||||
|
TARGET_RPS: ${{ vars.LOAD_TEST_TARGET_RPS || '500' }}
|
||||||
|
DURATION: ${{ vars.LOAD_TEST_DURATION || '300s' }}
|
||||||
|
K6_CLOUD_TOKEN: ${{ secrets.K6_CLOUD_TOKEN || '' }}
|
||||||
|
K6_CLOUD_PROJECT_ID: ${{ vars.K6_CLOUD_PROJECT_ID || '' }}
|
||||||
|
|
||||||
|
- name: Upload load test report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: load-test-report-${{ github.sha }}
|
||||||
|
path: scripts/load-test/reports/
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Check P99 thresholds
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ -f scripts/load-test/reports/threshold-results.json ]; then
|
||||||
|
FAILURES=$(jq -r '[.services | to_entries[] | select(.value.exitCode != 0) | .key] | join(", ")' scripts/load-test/reports/threshold-results.json 2>/dev/null || echo "")
|
||||||
|
if [ -n "$FAILURES" ] && [ "$FAILURES" != "" ]; then
|
||||||
|
echo "❌ Load test failures: $FAILURES"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "✅ All load tests passed"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "⚠️ No threshold results file found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Validate auto-scaling
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
SUMMARY_FILE=$(ls scripts/load-test/reports/*-summary-*.json 2>/dev/null | head -1)
|
||||||
|
if [ -n "$SUMMARY_FILE" ]; then
|
||||||
|
MAX_VUS=$(jq -r '.metrics.vus.max // 0' "$SUMMARY_FILE")
|
||||||
|
TARGET_VUS=20
|
||||||
|
if [ "$(echo "$MAX_VUS >= $TARGET_VUS" | bc -l)" -eq 1 ]; then
|
||||||
|
echo "✅ Auto-scaling validated: max VUs ($MAX_VUS) >= target ($TARGET_VUS)"
|
||||||
|
else
|
||||||
|
echo "⚠️ Auto-scaling below target: max VUs ($MAX_VUS) < target ($TARGET_VUS)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "⚠️ No summary file for auto-scaling validation"
|
||||||
|
fi
|
||||||
|
|||||||
203
.github/workflows/deploy.yml
vendored
@@ -12,6 +12,7 @@ concurrency:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: "20"
|
NODE_VERSION: "20"
|
||||||
|
PNPM_VERSION: "9"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
detect-environment:
|
detect-environment:
|
||||||
@@ -19,6 +20,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
environment: ${{ steps.detect.outputs.environment }}
|
environment: ${{ steps.detect.outputs.environment }}
|
||||||
|
tag: ${{ steps.tag.outputs.tag }}
|
||||||
steps:
|
steps:
|
||||||
- name: Detect deployment target
|
- name: Detect deployment target
|
||||||
id: detect
|
id: detect
|
||||||
@@ -28,13 +30,59 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "environment=staging" >> $GITHUB_OUTPUT
|
echo "environment=staging" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
- name: Calculate tag
|
||||||
|
id: tag
|
||||||
|
run: |
|
||||||
|
if [ "${{ steps.detect.outputs.environment }}" = "production" ]; then
|
||||||
|
echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
terraform-apply:
|
||||||
|
name: Terraform Apply
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: detect-environment
|
||||||
|
environment: ${{ needs.detect-environment.outputs.environment }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup Terraform
|
||||||
|
uses: hashicorp/setup-terraform@v3
|
||||||
|
with:
|
||||||
|
terraform_version: "~> 1.5"
|
||||||
|
- name: Terraform Init
|
||||||
|
working-directory: infra/environments/${{ needs.detect-environment.outputs.environment }}
|
||||||
|
run: terraform init -backend-config="bucket=shieldai-${{ needs.detect-environment.outputs.environment }}-terraform-state"
|
||||||
|
- name: Terraform Plan
|
||||||
|
id: plan
|
||||||
|
working-directory: infra/environments/${{ needs.detect-environment.outputs.environment }}
|
||||||
|
run: |
|
||||||
|
terraform plan \
|
||||||
|
-var="hibp_api_key=${{ secrets.HIBP_API_KEY }}" \
|
||||||
|
-var="resend_api_key=${{ secrets.RESEND_API_KEY }}" \
|
||||||
|
-var="sentry_dsn=${{ secrets.SENTRY_DSN }}" \
|
||||||
|
-var="datadog_api_key=${{ secrets.DATADOG_API_KEY }}" \
|
||||||
|
-no-color | tee /tmp/terraform-plan.out
|
||||||
|
- name: Terraform Apply
|
||||||
|
working-directory: infra/environments/${{ needs.detect-environment.outputs.environment }}
|
||||||
|
run: |
|
||||||
|
terraform apply -auto-approve \
|
||||||
|
-var="hibp_api_key=${{ secrets.HIBP_API_KEY }}" \
|
||||||
|
-var="resend_api_key=${{ secrets.RESEND_API_KEY }}" \
|
||||||
|
-var="sentry_dsn=${{ secrets.SENTRY_DSN }}" \
|
||||||
|
-var="datadog_api_key=${{ secrets.DATADOG_API_KEY }}"
|
||||||
|
env:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
AWS_DEFAULT_REGION: us-east-1
|
||||||
|
|
||||||
build-and-push:
|
build-and-push:
|
||||||
name: Build and Push Docker Images
|
name: Build and Push Docker Images
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: detect-environment
|
needs: [detect-environment]
|
||||||
environment: ${{ needs.detect-environment.outputs.environment }}
|
environment: ${{ needs.detect-environment.outputs.environment }}
|
||||||
strategy:
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- name: api
|
- name: api
|
||||||
@@ -47,6 +95,8 @@ jobs:
|
|||||||
dockerfile: services/voiceprint/Dockerfile
|
dockerfile: services/voiceprint/Dockerfile
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- name: Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Login to Container Registry
|
- name: Login to Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
@@ -55,47 +105,138 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Calculate image tag
|
- name: Calculate image tag
|
||||||
id: tag
|
id: tag
|
||||||
run: |
|
run: echo "tag=${{ needs.detect-environment.outputs.tag }}" >> $GITHUB_OUTPUT
|
||||||
if [ "${{ needs.detect-environment.outputs.environment }}" = "production" ]; then
|
|
||||||
echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "tag=staging-${{ github.sha }}" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
- name: Build and push ${{ matrix.name }}
|
- name: Build and push ${{ matrix.name }}
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ${{ matrix.dockerfile }}
|
file: ${{ matrix.dockerfile }}
|
||||||
push: true
|
push: true
|
||||||
tags: ghcr.io/${{ github.repository_owner }}/shieldai-${{ matrix.name }}:${{ steps.tag.outputs.tag }}
|
tags: |
|
||||||
|
ghcr.io/${{ github.repository_owner }}/shieldai-${{ matrix.name }}:${{ steps.tag.outputs.tag }}
|
||||||
|
ghcr.io/${{ github.repository_owner }}/shieldai-${{ matrix.name }}:latest
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
deploy:
|
deploy-ecs:
|
||||||
name: Deploy to ${{ needs.detect-environment.outputs.environment }}
|
name: Deploy to ECS
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [detect-environment, build-and-push]
|
needs: [detect-environment, terraform-apply, build-and-push]
|
||||||
environment: ${{ needs.detect-environment.outputs.environment }}
|
environment: ${{ needs.detect-environment.outputs.environment }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
service: [api, darkwatch, spamshield, voiceprint]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Calculate deployment tag
|
- name: Configure AWS
|
||||||
id: tag
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
run: |
|
|
||||||
if [ "${{ needs.detect-environment.outputs.environment }}" = "production" ]; then
|
|
||||||
echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "tag=staging-${{ github.sha }}" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
- name: Deploy via Docker Compose
|
|
||||||
uses: appleboy/ssh-action@v1
|
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.DEPLOY_HOST }}
|
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
username: ${{ secrets.DEPLOY_USER }}
|
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
aws-region: us-east-1
|
||||||
script: |
|
- name: Update ECS Service
|
||||||
cd /opt/shieldai
|
run: |
|
||||||
export DOCKER_TAG="${{ steps.tag.outputs.tag }}"
|
IMAGE="ghcr.io/${{ github.repository_owner }}/shieldai-${{ matrix.service }}:${{ needs.detect-environment.outputs.tag }}"
|
||||||
export ENVIRONMENT="${{ needs.detect-environment.outputs.environment }}"
|
CLUSTER="shieldai-${{ needs.detect-environment.outputs.environment }}"
|
||||||
docker compose pull
|
SERVICE="${{ matrix.service }}"
|
||||||
docker compose up -d
|
|
||||||
docker image prune -f
|
TASK_DEF=$(aws ecs describe-task-definition \
|
||||||
|
--task-definition "${CLUSTER}-${SERVICE}" \
|
||||||
|
--query 'taskDefinition' --output json)
|
||||||
|
|
||||||
|
NEW_TASK_DEF=$(echo "$TASK_DEF" | jq \
|
||||||
|
--arg image "$IMAGE" \
|
||||||
|
'.containerDefinitions[0].image = $image')
|
||||||
|
|
||||||
|
NEW_TASK_DEF_ARN=$(echo "$NEW_TASK_DEF" | \
|
||||||
|
aws ecs register-task-definition \
|
||||||
|
--family "${CLUSTER}-${SERVICE}" \
|
||||||
|
--cli-input-json - \
|
||||||
|
--query 'taskDefinition.taskDefinitionArn' --output text)
|
||||||
|
|
||||||
|
aws ecs update-service \
|
||||||
|
--cluster "$CLUSTER" \
|
||||||
|
--service "${CLUSTER}-${SERVICE}" \
|
||||||
|
--task-definition "$NEW_TASK_DEF_ARN" \
|
||||||
|
--force-new-deployment
|
||||||
|
|
||||||
|
echo "Deployed $IMAGE to $SERVICE"
|
||||||
|
|
||||||
|
health-check:
|
||||||
|
name: Post-Deploy Health Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [detect-environment, deploy-ecs]
|
||||||
|
environment: ${{ needs.detect-environment.outputs.environment }}
|
||||||
|
steps:
|
||||||
|
- name: Configure AWS
|
||||||
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
|
with:
|
||||||
|
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
|
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
aws-region: us-east-1
|
||||||
|
- name: Wait for deployment
|
||||||
|
run: sleep 30
|
||||||
|
- name: Health Check
|
||||||
|
id: health
|
||||||
|
run: |
|
||||||
|
ENV="${{ needs.detect-environment.outputs.environment }}"
|
||||||
|
CLUSTER="shieldai-${ENV}"
|
||||||
|
|
||||||
|
ALB_DNS=$(aws elbv2 describe-load-balancers \
|
||||||
|
--query "LoadBalancers[?contains(LoadBalancerName, '${CLUSTER}-alb')].DNSName" \
|
||||||
|
--output text)
|
||||||
|
|
||||||
|
if [ -z "$ALB_DNS" ]; then
|
||||||
|
echo "Health check failed: ALB DNS not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "ALB DNS: $ALB_DNS"
|
||||||
|
|
||||||
|
FAILED=0
|
||||||
|
for service in api darkwatch spamshield voiceprint; do
|
||||||
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
"https://${ALB_DNS}/health" || true)
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
echo "Health check passed: $service"
|
||||||
|
else
|
||||||
|
echo "Health check failed: $service (HTTP $HTTP_CODE)"
|
||||||
|
FAILED=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$FAILED" -eq 1 ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rollback:
|
||||||
|
name: Rollback on Failure
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [detect-environment, deploy-ecs, health-check]
|
||||||
|
environment: ${{ needs.detect-environment.outputs.environment }}
|
||||||
|
if: failure() && needs.health-check.result == 'failure'
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
service: [api, darkwatch, spamshield, voiceprint]
|
||||||
|
steps:
|
||||||
|
- name: Configure AWS
|
||||||
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
|
with:
|
||||||
|
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
|
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
aws-region: us-east-1
|
||||||
|
- name: Rollback ECS Service
|
||||||
|
run: |
|
||||||
|
CLUSTER="shieldai-${{ needs.detect-environment.outputs.environment }}"
|
||||||
|
SERVICE="${{ matrix.service }}"
|
||||||
|
|
||||||
|
aws ecs update-service \
|
||||||
|
--cluster "$CLUSTER" \
|
||||||
|
--service "${CLUSTER}-${SERVICE}" \
|
||||||
|
--rollback \
|
||||||
|
--no-cli-auto-prompt
|
||||||
|
|
||||||
|
echo "Rolled back $SERVICE"
|
||||||
|
|||||||
105
.github/workflows/load-test.yml
vendored
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
name: Load Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
target_rps:
|
||||||
|
description: 'Target requests per second'
|
||||||
|
required: false
|
||||||
|
default: '500'
|
||||||
|
duration:
|
||||||
|
description: 'Test duration'
|
||||||
|
required: false
|
||||||
|
default: '300s'
|
||||||
|
service:
|
||||||
|
description: 'Service to test (all, api, darkwatch, spamshield, voiceprint)'
|
||||||
|
required: false
|
||||||
|
default: 'all'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: "20"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
load-test:
|
||||||
|
name: Load Test (${{ github.event.inputs.service || 'all' }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
environment: staging
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install k6
|
||||||
|
run: |
|
||||||
|
K6_VERSION="v0.50.0"
|
||||||
|
K6_URL="https://github.com/grafana/k6/releases/download/${K6_VERSION}/k6-linux-amd64.tar.gz"
|
||||||
|
K6_SHA256="d950a2408d0be2dc81aef397a7c984a1d84271d7ae94ff7a47d08371904f0800"
|
||||||
|
curl -sSL "${K6_URL}" -o k6.tar.gz
|
||||||
|
echo "${K6_SHA256} k6.tar.gz" | sha256sum --check --strict -
|
||||||
|
tar xzf k6.tar.gz
|
||||||
|
sudo mv k6 /usr/local/bin/
|
||||||
|
k6 version
|
||||||
|
|
||||||
|
- name: Validate required secrets
|
||||||
|
run: |
|
||||||
|
if [ -z "$API_TOKEN" ]; then
|
||||||
|
echo "❌ LOAD_TEST_API_TOKEN secret is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run load tests
|
||||||
|
run: |
|
||||||
|
chmod +x scripts/load-test/run-all.sh
|
||||||
|
./scripts/load-test/run-all.sh ${{ github.event.inputs.service || 'all' }}
|
||||||
|
env:
|
||||||
|
LOAD_TEST_BASE_URL: ${{ secrets.LOAD_TEST_BASE_URL || 'http://localhost:3000' }}
|
||||||
|
API_TOKEN: ${{ secrets.LOAD_TEST_API_TOKEN }}
|
||||||
|
TARGET_RPS: ${{ github.event.inputs.target_rps || '500' }}
|
||||||
|
DURATION: ${{ github.event.inputs.duration || '300s' }}
|
||||||
|
K6_CLOUD_TOKEN: ${{ secrets.K6_CLOUD_TOKEN || '' }}
|
||||||
|
K6_CLOUD_PROJECT_ID: ${{ vars.K6_CLOUD_PROJECT_ID || '' }}
|
||||||
|
|
||||||
|
- name: Upload load test report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: load-test-report-${{ github.sha }}
|
||||||
|
path: scripts/load-test/reports/
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Check P99 thresholds
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ -f scripts/load-test/reports/threshold-results.json ]; then
|
||||||
|
FAILURES=$(jq -r '[.services | to_entries[] | select(.value.exitCode != 0) | .key] | join(", ")' scripts/load-test/reports/threshold-results.json 2>/dev/null || echo "")
|
||||||
|
if [ -n "$FAILURES" ] && [ "$FAILURES" != "" ]; then
|
||||||
|
echo "❌ Load test failures: $FAILURES"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "✅ All load tests passed"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "⚠️ No threshold results file found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Validate auto-scaling
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
SUMMARY_FILE=$(ls scripts/load-test/reports/*-summary-*.json 2>/dev/null | head -1)
|
||||||
|
if [ -n "$SUMMARY_FILE" ]; then
|
||||||
|
MAX_VUS=$(jq -r '.metrics.vus.max // 0' "$SUMMARY_FILE")
|
||||||
|
TARGET_VUS=20
|
||||||
|
if [ "$(echo "$MAX_VUS >= $TARGET_VUS" | bc -l)" -eq 1 ]; then
|
||||||
|
echo "✅ Auto-scaling validated: max VUs ($MAX_VUS) >= target ($TARGET_VUS)"
|
||||||
|
else
|
||||||
|
echo "⚠️ Auto-scaling below target: max VUs ($MAX_VUS) < target ($TARGET_VUS)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "⚠️ No summary file for auto-scaling validation"
|
||||||
|
fi
|
||||||
2
.gitignore
vendored
@@ -3,3 +3,5 @@ dist
|
|||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
load-tests/voiceprint/results/
|
||||||
|
.turbo
|
||||||
|
|||||||
1
.turbo/cache/47854326d2b77c8e-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{"packages/types/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/types/dist/index.js":{"size":3531,"mtime_nanos":1778380725084978870,"mode":420,"is_dir":false},"packages/types/dist/index.js.map":{"size":2294,"mtime_nanos":1778380725084978870,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts.map":{"size":278,"mtime_nanos":1778380725078978662,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts":{"size":629,"mtime_nanos":1778380725078978662,"mode":420,"is_dir":false},"packages/types/dist/requestId.js":{"size":2329,"mtime_nanos":1778380725074978523,"mode":420,"is_dir":false},"packages/types/dist/requestId.js.map":{"size":1785,"mtime_nanos":1778380725074978523,"mode":420,"is_dir":false},"packages/types/.turbo/turbo-build.log":{"size":78,"mtime_nanos":1778380725118980048,"mode":420,"is_dir":false},"packages/types/dist/index.d.ts.map":{"size":7296,"mtime_nanos":1778380725099979390,"mode":420,"is_dir":false},"packages/types/dist/index.d.ts":{"size":9902,"mtime_nanos":1778380725099979390,"mode":420,"is_dir":false}},"order":["packages/types/.turbo/turbo-build.log","packages/types/dist","packages/types/dist/index.d.ts","packages/types/dist/index.d.ts.map","packages/types/dist/index.js","packages/types/dist/index.js.map","packages/types/dist/requestId.d.ts","packages/types/dist/requestId.d.ts.map","packages/types/dist/requestId.js","packages/types/dist/requestId.js.map"]}
|
||||||
1
.turbo/cache/47854326d2b77c8e-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"47854326d2b77c8e","duration":744,"sha":"de0ddac65df311d7ef051c48ad6291d8de8618f3","dirty_hash":"a8bcf9ec37f7505b9b259118f068359e59ffb7bdae53135b3b2ec7ca027f5c2d"}
|
||||||
BIN
.turbo/cache/47854326d2b77c8e.tar.zst
vendored
Normal file
BIN
assets/ads/gd_landscape_1200x628.png
Normal file
|
After Width: | Height: | Size: 207 KiB |
39
assets/ads/gd_landscape_1200x628.svg
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="628" viewBox="0 0 1200 628">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgGrad2" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#0a0f1e"/>
|
||||||
|
<stop offset="60%" stop-color="#0a0f1e"/>
|
||||||
|
<stop offset="100%" stop-color="#0c1628"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6"/>
|
||||||
|
<stop offset="100%" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shieldGrad" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6"/>
|
||||||
|
<stop offset="100%" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="628" fill="url(#bgGrad2)"/>
|
||||||
|
<rect width="1200" height="5" fill="url(#brandBar)"/>
|
||||||
|
<circle cx="830" cy="314" r="240" fill="#3b82f608"/>
|
||||||
|
<circle cx="830" cy="314" r="180" fill="#3b82f606"/>
|
||||||
|
<circle cx="830" cy="314" r="220" fill="none" stroke="#3b82f615" stroke-width="1" stroke-dasharray="8 8"/>
|
||||||
|
|
||||||
|
<!-- Digital shield icon (large, right side) -->
|
||||||
|
<g transform="translate(830, 314)">
|
||||||
|
<path d="M-70,-60 L70,-60 L75,20 Q75,60 40,80 L0,95 L-40,80 Q-75,60 -75,20 Z" fill="none" stroke="url(#shieldGrad)" stroke-width="3"/>
|
||||||
|
<path d="M-30,-10 L0,25 L35,-20" fill="none" stroke="#22c55e" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<text x="0" y="130" font-family="system-ui, sans-serif" font-size="14" fill="#94a3b8" text-anchor="middle">AI-Powered Protection</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Left side: text -->
|
||||||
|
<text x="60" y="220" font-family="system-ui, sans-serif" font-size="44" font-weight="700" fill="#f1f5f9">Your Family Deserves</text>
|
||||||
|
<text x="60" y="280" font-family="system-ui, sans-serif" font-size="44" font-weight="700" fill="#06b6d4">AI Protection</text>
|
||||||
|
|
||||||
|
<text x="60" y="340" font-family="system-ui, sans-serif" font-size="18" fill="#94a3b8">Real-time AI voice clone detection</text>
|
||||||
|
<text x="60" y="368" font-family="system-ui, sans-serif" font-size="18" fill="#94a3b8">Dark web monitoring • Spam blocking</text>
|
||||||
|
|
||||||
|
<rect x="60" y="410" width="200" height="52" rx="26" fill="#3b82f6"/>
|
||||||
|
<text x="160" y="442" font-family="system-ui, sans-serif" font-size="18" font-weight="600" fill="#f1f5f9" text-anchor="middle">Join the Waitlist</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
BIN
assets/ads/gd_portrait_600x750.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
45
assets/ads/gd_portrait_600x750.svg
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="750" viewBox="0 0 600 750">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgGrad3" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#0a0f1e"/>
|
||||||
|
<stop offset="100%" stop-color="#050812"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6"/>
|
||||||
|
<stop offset="100%" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="glow">
|
||||||
|
<feGaussianBlur stdDeviation="3" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="600" height="750" fill="url(#bgGrad3)"/>
|
||||||
|
<rect width="600" height="5" fill="url(#brandBar)"/>
|
||||||
|
|
||||||
|
<!-- Phone icon -->
|
||||||
|
<g transform="translate(300, 260)">
|
||||||
|
<rect x="-60" y="-100" width="120" height="200" rx="18" fill="none" stroke="#3b82f6" stroke-width="3"/>
|
||||||
|
<circle cx="0" cy="80" r="6" fill="#3b82f6"/>
|
||||||
|
<!-- Sound waves -->
|
||||||
|
<path d="M-30,-30 Q-50,-10 -30,10" fill="none" stroke="#06b6d4" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/>
|
||||||
|
<path d="M-20,-45 Q-65,-10 -20,25" fill="none" stroke="#06b6d4" stroke-width="2.5" stroke-linecap="round" opacity="0.9"/>
|
||||||
|
<path d="M-10,-60 Q-80,-10 -10,40" fill="none" stroke="#06b6d4" stroke-width="2.5" stroke-linecap="round" filter="url(#glow)"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Warning indicator -->
|
||||||
|
<g transform="translate(300, 80)">
|
||||||
|
<path d="M0,-30 L-20,0 L20,0 Z" fill="#f59e0b"/>
|
||||||
|
<circle cx="0" cy="10" r="4" fill="#f59e0b"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="300" y="420" font-family="system-ui, sans-serif" font-size="34" font-weight="700" fill="#f1f5f9" text-anchor="middle">Voice Clone</text>
|
||||||
|
<text x="300" y="460" font-family="system-ui, sans-serif" font-size="34" font-weight="700" fill="#06b6d4" text-anchor="middle">Detection</text>
|
||||||
|
|
||||||
|
<text x="300" y="510" font-family="system-ui, sans-serif" font-size="16" fill="#94a3b8" text-anchor="middle">AI detects synthetic voices</text>
|
||||||
|
<text x="300" y="535" font-family="system-ui, sans-serif" font-size="16" fill="#94a3b8" text-anchor="middle">in real time with 99.7% accuracy</text>
|
||||||
|
|
||||||
|
<rect x="200" y="580" width="200" height="50" rx="25" fill="#3b82f6"/>
|
||||||
|
<text x="300" y="611" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="#f1f5f9" text-anchor="middle">Learn How We Detect It</text>
|
||||||
|
|
||||||
|
<text x="300" y="710" font-family="system-ui, sans-serif" font-size="13" fill="#64748b" text-anchor="middle">ShieldAI — AI-Powered Identity Protection</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
BIN
assets/ads/gd_square_1200x1200.png
Normal file
|
After Width: | Height: | Size: 268 KiB |
45
assets/ads/gd_square_1200x1200.svg
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" viewBox="0 0 1200 1200">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgGrad" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#0a0f1e"/>
|
||||||
|
<stop offset="100%" stop-color="#050812"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6"/>
|
||||||
|
<stop offset="100%" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shieldGrad" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6"/>
|
||||||
|
<stop offset="100%" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="1200" fill="url(#bgGrad)"/>
|
||||||
|
<rect width="1200" height="6" fill="url(#brandBar)"/>
|
||||||
|
<text x="600" y="160" font-family="system-ui, sans-serif" font-size="52" font-weight="700" fill="#f1f5f9" text-anchor="middle">3 Protections, 1 Platform</text>
|
||||||
|
<text x="600" y="220" font-family="system-ui, sans-serif" font-size="24" fill="#94a3b8" text-anchor="middle">AI-Powered Identity Protection for Everyone</text>
|
||||||
|
<rect x="120" y="300" width="280" height="320" rx="16" fill="#1a2332" stroke="#1e293b" stroke-width="1.5"/>
|
||||||
|
<g transform="translate(260, 420)">
|
||||||
|
<circle cx="0" cy="0" r="50" fill="#06b6d422" stroke="#06b6d4" stroke-width="2"/>
|
||||||
|
<path d="M0,-40 Q30,-35 40,-10 Q45,5 35,20 L25,30 L0,40 L-25,30 L-35,20 Q-45,5 -40,-10 Q-30,-35 0,-40 Z" fill="none" stroke="#06b6d4" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M-12,0 L-4,8 L12,-10" fill="none" stroke="#f1f5f9" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<text x="260" y="510" font-family="system-ui, sans-serif" font-size="22" font-weight="600" fill="#f1f5f9" text-anchor="middle">VoicePrint</text>
|
||||||
|
<text x="260" y="540" font-family="system-ui, sans-serif" font-size="16" fill="#94a3b8" text-anchor="middle">AI Voice Clone Detection</text>
|
||||||
|
<rect x="460" y="300" width="280" height="320" rx="16" fill="#1a2332" stroke="#1e293b" stroke-width="1.5"/>
|
||||||
|
<g transform="translate(600, 420)">
|
||||||
|
<circle cx="0" cy="0" r="50" fill="#3b82f622" stroke="#3b82f6" stroke-width="2"/>
|
||||||
|
<path d="M-35,-30 L35,-30 L40,10 Q40,30 25,40 L0,45 L-25,40 Q-40,30 -40,10 Z" fill="none" stroke="#3b82f6" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M0,5 L0,25 M-10,15 L10,15" fill="none" stroke="#f1f5f9" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<text x="600" y="510" font-family="system-ui, sans-serif" font-size="22" font-weight="600" fill="#f1f5f9" text-anchor="middle">DarkWatch</text>
|
||||||
|
<text x="600" y="540" font-family="system-ui, sans-serif" font-size="16" fill="#94a3b8" text-anchor="middle">Dark Web Monitoring</text>
|
||||||
|
<rect x="800" y="300" width="280" height="320" rx="16" fill="#1a2332" stroke="#1e293b" stroke-width="1.5"/>
|
||||||
|
<g transform="translate(940, 420)">
|
||||||
|
<circle cx="0" cy="0" r="50" fill="#22c55e22" stroke="#22c55e" stroke-width="2"/>
|
||||||
|
<path d="M-40,-10 Q-40,-40 0,-40 Q40,-40 40,-10 Q40,15 20,30 L0,40 L-20,30 Q-40,15 -40,-10 Z" fill="none" stroke="#22c55e" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M-15,0 L-5,10 L18,-12" fill="none" stroke="#f1f5f9" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<text x="940" y="510" font-family="system-ui, sans-serif" font-size="22" font-weight="600" fill="#f1f5f9" text-anchor="middle">SpamShield</text>
|
||||||
|
<text x="940" y="540" font-family="system-ui, sans-serif" font-size="16" fill="#94a3b8" text-anchor="middle">Spam Call & Text Blocking</text>
|
||||||
|
<text x="600" y="1100" font-family="system-ui, sans-serif" font-size="18" fill="#64748b" text-anchor="middle">Join 1,000+ Early Adopters</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.6 KiB |
633
assets/ads/generate_assets.py
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate ShieldAI ad creative SVGs for Google Display and Meta campaigns."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
OUT = os.path.join(os.path.dirname(__file__))
|
||||||
|
|
||||||
|
# Brand colors
|
||||||
|
DARK_BG = "#0a0f1e"
|
||||||
|
CARD_BG = "#1a2332"
|
||||||
|
TEXT_PRIMARY = "#f1f5f9"
|
||||||
|
TEXT_SECONDARY = "#94a3b8"
|
||||||
|
TEXT_MUTED = "#64748b"
|
||||||
|
ACCENT_BLUE = "#3b82f6"
|
||||||
|
ACCENT_CYAN = "#06b6d4"
|
||||||
|
SUCCESS = "#22c55e"
|
||||||
|
ERROR = "#ef4444"
|
||||||
|
WARNING = "#f59e0b"
|
||||||
|
BORDER = "#1e293b"
|
||||||
|
|
||||||
|
def shield_logo_svg(size=40, x=0, y=0):
|
||||||
|
return f'''<g transform="translate({x},{y})">
|
||||||
|
<circle cx="{size//2}" cy="{size//2}" r="{size//2}" fill="url(shieldGrad)"/>
|
||||||
|
<path d="M{size//2-10},{size//2-8} L{size//2+10},{size//2-8} L{size//2+10},{size//2+6} Q{size//2},{size//2+14} {size//2},{size//2+14} Q{size//2},{size//2+14} {size//2-10},{size//2+6} Z" fill="none" stroke="{TEXT_PRIMARY}" stroke-width="2.5"/>
|
||||||
|
<path d="M{size//2-4},{size//2-2} L{size//2},{size//2+4} L{size//2+7},{size//2-5}" fill="none" stroke="{TEXT_PRIMARY}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>'''
|
||||||
|
|
||||||
|
def brand_bar(w, h):
|
||||||
|
return f'''<rect width="{w}" height="{h}" fill="url(brandBar)"/>'''
|
||||||
|
|
||||||
|
def safe_text(text, max_len=80):
|
||||||
|
return text[:max_len] if len(text) > max_len else text
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# GOOGLE DISPLAY ASSETS
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def gd_square():
|
||||||
|
"""1:1 (1200x1200) — '3 Protections, 1 Platform' three-icon panel"""
|
||||||
|
w, h = 1200, 1200
|
||||||
|
icon_size = 100
|
||||||
|
box_w, box_h = 280, 320
|
||||||
|
gap = 60
|
||||||
|
total_w = 3 * box_w + 2 * gap
|
||||||
|
start_x = (w - total_w) // 2
|
||||||
|
top_y = 300
|
||||||
|
|
||||||
|
icons_data = [
|
||||||
|
("VoicePrint", "AI Voice Clone Detection", ACCENT_CYAN, [
|
||||||
|
"M0,-40 Q30,-35 40,-10 Q45,5 35,20 L25,30 L0,40 L-25,30 L-35,20 Q-45,5 -40,-10 Q-30,-35 0,-40 Z",
|
||||||
|
"M-12,0 L-4,8 L12,-10"
|
||||||
|
]),
|
||||||
|
("DarkWatch", "Dark Web Monitoring", ACCENT_BLUE, [
|
||||||
|
"M-35,-30 L35,-30 L40,10 Q40,30 25,40 L0,45 L-25,40 Q-40,30 -40,10 Z",
|
||||||
|
"M0,5 L0,25 M-10,15 L10,15"
|
||||||
|
]),
|
||||||
|
("SpamShield", "Spam Call & Text Blocking", SUCCESS, [
|
||||||
|
"M-40,-10 Q-40,-40 0,-40 Q40,-40 40,-10 Q40,15 20,30 L0,40 L-20,30 Q-40,15 -40,-10 Z",
|
||||||
|
"M-15,0 L-5,10 L18,-12"
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgGrad" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="{DARK_BG}"/>
|
||||||
|
<stop offset="100%" stop-color="#050812"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="{ACCENT_BLUE}"/>
|
||||||
|
<stop offset="100%" stop-color="{ACCENT_CYAN}"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shieldGrad" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="{ACCENT_BLUE}"/>
|
||||||
|
<stop offset="100%" stop-color="{ACCENT_CYAN}"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="{w}" height="{h}" fill="url(#bgGrad)"/>
|
||||||
|
{brand_bar(w, 6)}
|
||||||
|
<text x="{w//2}" y="160" font-family="system-ui, sans-serif" font-size="52" font-weight="700" fill="{TEXT_PRIMARY}" text-anchor="middle">3 Protections, 1 Platform</text>
|
||||||
|
<text x="{w//2}" y="220" font-family="system-ui, sans-serif" font-size="24" fill="{TEXT_SECONDARY}" text-anchor="middle">AI-Powered Identity Protection for Everyone</text>'''
|
||||||
|
|
||||||
|
for i, (name, desc, color, paths) in enumerate(icons_data):
|
||||||
|
cx = start_x + i * (box_w + gap) + box_w // 2
|
||||||
|
cy = top_y + box_h // 2
|
||||||
|
|
||||||
|
svg += f'''
|
||||||
|
<rect x="{start_x + i * (box_w + gap)}" y="{top_y}" width="{box_w}" height="{box_h}" rx="16" fill="{CARD_BG}" stroke="{BORDER}" stroke-width="1.5"/>'''
|
||||||
|
svg += f'''
|
||||||
|
<g transform="translate({cx}, {cy - 40})">
|
||||||
|
<circle cx="0" cy="0" r="50" fill="{color}22" stroke="{color}" stroke-width="2"/>
|
||||||
|
<path d="{paths[0]}" fill="none" stroke="{color}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="{paths[1]}" fill="none" stroke="{TEXT_PRIMARY}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>'''
|
||||||
|
svg += f'''
|
||||||
|
<text x="{cx}" y="{cy + 50}" font-family="system-ui, sans-serif" font-size="22" font-weight="600" fill="{TEXT_PRIMARY}" text-anchor="middle">{name}</text>
|
||||||
|
<text x="{cx}" y="{cy + 80}" font-family="system-ui, sans-serif" font-size="16" fill="{TEXT_SECONDARY}" text-anchor="middle">{desc}</text>'''
|
||||||
|
|
||||||
|
svg += f'''
|
||||||
|
<text x="{w//2}" y="{h - 100}" font-family="system-ui, sans-serif" font-size="18" fill="{TEXT_MUTED}" text-anchor="middle">Join 1,000+ Early Adopters</text>
|
||||||
|
</svg>'''
|
||||||
|
return svg
|
||||||
|
|
||||||
|
|
||||||
|
def gd_landscape():
|
||||||
|
"""1.91:1 (1200x628) — 'Your Family Deserves AI Protection' family + shield"""
|
||||||
|
w, h = 1200, 628
|
||||||
|
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgGrad2" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="{DARK_BG}"/>
|
||||||
|
<stop offset="60%" stop-color="{DARK_BG}"/>
|
||||||
|
<stop offset="100%" stop-color="#0c1628"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="{ACCENT_BLUE}"/>
|
||||||
|
<stop offset="100%" stop-color="{ACCENT_CYAN}"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shieldGrad" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="{ACCENT_BLUE}"/>
|
||||||
|
<stop offset="100%" stop-color="{ACCENT_CYAN}"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="{w}" height="{h}" fill="url(#bgGrad2)"/>
|
||||||
|
{brand_bar(w, 5)}
|
||||||
|
<circle cx="830" cy="314" r="240" fill="{ACCENT_BLUE}08"/>
|
||||||
|
<circle cx="830" cy="314" r="180" fill="{ACCENT_BLUE}06"/>
|
||||||
|
<circle cx="830" cy="314" r="220" fill="none" stroke="{ACCENT_BLUE}15" stroke-width="1" stroke-dasharray="8 8"/>
|
||||||
|
|
||||||
|
<!-- Digital shield icon (large, right side) -->
|
||||||
|
<g transform="translate(830, 314)">
|
||||||
|
<path d="M-70,-60 L70,-60 L75,20 Q75,60 40,80 L0,95 L-40,80 Q-75,60 -75,20 Z" fill="none" stroke="url(#shieldGrad)" stroke-width="3"/>
|
||||||
|
<path d="M-30,-10 L0,25 L35,-20" fill="none" stroke="{SUCCESS}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<text x="0" y="130" font-family="system-ui, sans-serif" font-size="14" fill="{TEXT_SECONDARY}" text-anchor="middle">AI-Powered Protection</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Left side: text -->
|
||||||
|
<text x="60" y="220" font-family="system-ui, sans-serif" font-size="44" font-weight="700" fill="{TEXT_PRIMARY}">Your Family Deserves</text>
|
||||||
|
<text x="60" y="280" font-family="system-ui, sans-serif" font-size="44" font-weight="700" fill="{ACCENT_CYAN}">AI Protection</text>
|
||||||
|
|
||||||
|
<text x="60" y="340" font-family="system-ui, sans-serif" font-size="18" fill="{TEXT_SECONDARY}">Real-time AI voice clone detection</text>
|
||||||
|
<text x="60" y="368" font-family="system-ui, sans-serif" font-size="18" fill="{TEXT_SECONDARY}">Dark web monitoring • Spam blocking</text>
|
||||||
|
|
||||||
|
<rect x="60" y="410" width="200" height="52" rx="26" fill="{ACCENT_BLUE}"/>
|
||||||
|
<text x="160" y="442" font-family="system-ui, sans-serif" font-size="18" font-weight="600" fill="{TEXT_PRIMARY}" text-anchor="middle">Join the Waitlist</text>
|
||||||
|
</svg>'''
|
||||||
|
return svg
|
||||||
|
|
||||||
|
|
||||||
|
def gd_portrait():
|
||||||
|
"""4:5 (600x750) — 'Voice Clone Detection' phone call visualization"""
|
||||||
|
w, h = 600, 750
|
||||||
|
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgGrad3" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="{DARK_BG}"/>
|
||||||
|
<stop offset="100%" stop-color="#050812"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="{ACCENT_BLUE}"/>
|
||||||
|
<stop offset="100%" stop-color="{ACCENT_CYAN}"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="glow">
|
||||||
|
<feGaussianBlur stdDeviation="3" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="{w}" height="{h}" fill="url(#bgGrad3)"/>
|
||||||
|
{brand_bar(w, 5)}
|
||||||
|
|
||||||
|
<!-- Phone icon -->
|
||||||
|
<g transform="translate(300, 260)">
|
||||||
|
<rect x="-60" y="-100" width="120" height="200" rx="18" fill="none" stroke="{ACCENT_BLUE}" stroke-width="3"/>
|
||||||
|
<circle cx="0" cy="80" r="6" fill="{ACCENT_BLUE}"/>
|
||||||
|
<!-- Sound waves -->
|
||||||
|
<path d="M-30,-30 Q-50,-10 -30,10" fill="none" stroke="{ACCENT_CYAN}" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/>
|
||||||
|
<path d="M-20,-45 Q-65,-10 -20,25" fill="none" stroke="{ACCENT_CYAN}" stroke-width="2.5" stroke-linecap="round" opacity="0.9"/>
|
||||||
|
<path d="M-10,-60 Q-80,-10 -10,40" fill="none" stroke="{ACCENT_CYAN}" stroke-width="2.5" stroke-linecap="round" filter="url(#glow)"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Warning indicator -->
|
||||||
|
<g transform="translate(300, 80)">
|
||||||
|
<path d="M0,-30 L-20,0 L20,0 Z" fill="{WARNING}"/>
|
||||||
|
<circle cx="0" cy="10" r="4" fill="{WARNING}"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="{w//2}" y="420" font-family="system-ui, sans-serif" font-size="34" font-weight="700" fill="{TEXT_PRIMARY}" text-anchor="middle">Voice Clone</text>
|
||||||
|
<text x="{w//2}" y="460" font-family="system-ui, sans-serif" font-size="34" font-weight="700" fill="{ACCENT_CYAN}" text-anchor="middle">Detection</text>
|
||||||
|
|
||||||
|
<text x="{w//2}" y="510" font-family="system-ui, sans-serif" font-size="16" fill="{TEXT_SECONDARY}" text-anchor="middle">AI detects synthetic voices</text>
|
||||||
|
<text x="{w//2}" y="535" font-family="system-ui, sans-serif" font-size="16" fill="{TEXT_SECONDARY}" text-anchor="middle">in real time with 99.7% accuracy</text>
|
||||||
|
|
||||||
|
<rect x="200" y="580" width="200" height="50" rx="25" fill="{ACCENT_BLUE}"/>
|
||||||
|
<text x="300" y="611" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="{TEXT_PRIMARY}" text-anchor="middle">Learn How We Detect It</text>
|
||||||
|
|
||||||
|
<text x="{w//2}" y="{h - 40}" font-family="system-ui, sans-serif" font-size="13" fill="{TEXT_MUTED}" text-anchor="middle">ShieldAI — AI-Powered Identity Protection</text>
|
||||||
|
</svg>'''
|
||||||
|
return svg
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# META CREATIVE A: Voice Clone Threat
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def meta_a_1x1():
|
||||||
|
"""1:1 (1080x1080) — split-screen family / AI distortion"""
|
||||||
|
w, h = 1080, 1080
|
||||||
|
half = w // 2
|
||||||
|
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgGradL" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#0a1528"/>
|
||||||
|
<stop offset="100%" stop-color="#0f1d35"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="bgGradR" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#1a0a0a"/>
|
||||||
|
<stop offset="100%" stop-color="#2d0f0f"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="{ACCENT_BLUE}"/>
|
||||||
|
<stop offset="100%" stop-color="{ACCENT_CYAN}"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="distortGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="{ERROR}66"/>
|
||||||
|
<stop offset="100%" stop-color="{ERROR}22"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="glitch">
|
||||||
|
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="3" result="noise"/>
|
||||||
|
<feDisplacementMap in="SourceGraphic" in2="noise" scale="8" xChannelSelector="R" yChannelSelector="G"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Left panel: normal family -->
|
||||||
|
<rect x="0" y="0" width="{half}" height="{h}" fill="url(#bgGradL)"/>
|
||||||
|
<circle cx="{half//2}" cy="280" r="60" fill="{ACCENT_BLUE}30" stroke="{ACCENT_BLUE}" stroke-width="2"/>
|
||||||
|
<circle cx="{half//2 - 60}" cy="220" r="40" fill="{ACCENT_BLUE}20" stroke="{ACCENT_BLUE}" stroke-width="1.5"/>
|
||||||
|
<circle cx="{half//2 + 70}" cy="230" r="35" fill="{ACCENT_BLUE}20" stroke="{ACCENT_BLUE}" stroke-width="1.5"/>
|
||||||
|
<circle cx="{half//2 - 30}" cy="360" r="45" fill="{ACCENT_BLUE}20" stroke="{ACCENT_BLUE}" stroke-width="1.5"/>
|
||||||
|
<rect x="{half//2 - 70}" y="420" width="140" height="180" rx="10" fill="{ACCENT_BLUE}15" stroke="{ACCENT_BLUE}" stroke-width="1.5" opacity="0.6"/>
|
||||||
|
<text x="{half//2}" y="680" font-family="system-ui, sans-serif" font-size="22" font-weight="600" fill="{TEXT_PRIMARY}" text-anchor="middle">Your Family</text>
|
||||||
|
<text x="{half//2}" y="710" font-family="system-ui, sans-serif" font-size="15" fill="{TEXT_SECONDARY}" text-anchor="middle">Real & Unfiltered</text>
|
||||||
|
|
||||||
|
<!-- Center divider with phone icon -->
|
||||||
|
<rect x="{half - 2}" y="0" width="4" height="{h}" fill="{BORDER}"/>
|
||||||
|
<g transform="translate({half}, {h//2 - 60})">
|
||||||
|
<rect x="-25" y="-50" width="50" height="100" rx="10" fill="{ACCENT_BLUE}" opacity="0.3"/>
|
||||||
|
<path d="M-10,-10 Q-20,0 -10,10" fill="none" stroke="{ERROR}" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<path d="M0,-20 Q-25,0 0,20" fill="none" stroke="{ERROR}" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<path d="M10,-30 Q-30,0 10,30" fill="none" stroke="{ERROR}" stroke-width="2" stroke-linecap="round" opacity="0.7"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Right panel: distorted/AI -->
|
||||||
|
<rect x="{half}" y="0" width="{half}" height="{h}" fill="url(#bgGradR)"/>
|
||||||
|
<g filter="url(#glitch)">
|
||||||
|
<circle cx="{half + half//2}" cy="280" r="60" fill="{ERROR}30" stroke="{ERROR}" stroke-width="2"/>
|
||||||
|
<circle cx="{half + half//2 - 60}" cy="220" r="40" fill="{ERROR}20" stroke="{ERROR}" stroke-width="1.5"/>
|
||||||
|
<circle cx="{half + half//2 + 70}" cy="230" r="35" fill="{ERROR}20" stroke="{ERROR}" stroke-width="1.5"/>
|
||||||
|
<circle cx="{half + half//2 - 30}" cy="360" r="45" fill="{ERROR}20" stroke="{ERROR}" stroke-width="1.5"/>
|
||||||
|
<rect x="{half + half//2 - 70}" y="420" width="140" height="180" rx="10" fill="{ERROR}15" stroke="{ERROR}" stroke-width="1.5" opacity="0.6"/>
|
||||||
|
</g>
|
||||||
|
<text x="{half + half//2}" y="680" font-family="system-ui, sans-serif" font-size="22" font-weight="600" fill="{ERROR}" text-anchor="middle">AI Clone</text>
|
||||||
|
<text x="{half + half//2}" y="710" font-family="system-ui, sans-serif" font-size="15" fill="{TEXT_MUTED}" text-anchor="middle">Synthetic & Dangerous</text>
|
||||||
|
|
||||||
|
<!-- Bottom brand bar -->
|
||||||
|
<rect x="0" y="{h - 90}" width="{w}" height="90" fill="{CARD_BG}"/>
|
||||||
|
<text x="{w//2}" y="{h - 55}" font-family="system-ui, sans-serif" font-size="22" font-weight="600" fill="{TEXT_PRIMARY}" text-anchor="middle">Your Family's Voice, Protected</text>
|
||||||
|
<text x="{w//2}" y="{h - 28}" font-family="system-ui, sans-serif" font-size="15" fill="{TEXT_SECONDARY}" text-anchor="middle">ShieldAI detects AI voice cloning with 99.7% accuracy</text>
|
||||||
|
</svg>'''
|
||||||
|
return svg
|
||||||
|
|
||||||
|
|
||||||
|
def meta_a_191():
|
||||||
|
"""1.91:1 (1200x628) — split-screen family / AI distortion"""
|
||||||
|
w, h = 1200, 628
|
||||||
|
half = w // 2
|
||||||
|
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgL" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#0a1528"/>
|
||||||
|
<stop offset="100%" stop-color="#0f1d35"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="bgR" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#1a0a0a"/>
|
||||||
|
<stop offset="100%" stop-color="#2d0f0f"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="{ACCENT_BLUE}"/>
|
||||||
|
<stop offset="100%" stop-color="{ACCENT_CYAN}"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="glitch2">
|
||||||
|
<feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="2" result="noise"/>
|
||||||
|
<feDisplacementMap in="SourceGraphic" in2="noise" scale="6" xChannelSelector="R" yChannelSelector="G"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="{half}" height="{h}" fill="url(#bgL)"/>
|
||||||
|
<circle cx="{half//2}" cy="{h//2 - 30}" r="35" fill="{ACCENT_BLUE}30" stroke="{ACCENT_BLUE}" stroke-width="2"/>
|
||||||
|
<circle cx="{half//2 - 50}" cy="{h//2 - 80}" r="25" fill="{ACCENT_BLUE}20" stroke="{ACCENT_BLUE}" stroke-width="1.5"/>
|
||||||
|
<circle cx="{half//2 + 55}" cy="{h//2 - 75}" r="22" fill="{ACCENT_BLUE}20" stroke="{ACCENT_BLUE}" stroke-width="1.5"/>
|
||||||
|
<text x="{half//2}" y="{h//2 + 60}" font-family="system-ui, sans-serif" font-size="20" font-weight="600" fill="{TEXT_PRIMARY}" text-anchor="middle">Your Family</text>
|
||||||
|
<rect x="0" y="{h - 50}" width="{half}" height="50" fill="{CARD_BG}"/>
|
||||||
|
<text x="{half//2}" y="{h - 22}" font-family="system-ui, sans-serif" font-size="13" fill="{TEXT_SECONDARY}" text-anchor="middle">Real voice, real moment</text>
|
||||||
|
|
||||||
|
<rect x="{half - 1}" y="0" width="3" height="{h}" fill="{BORDER}"/>
|
||||||
|
<rect x="{half}" y="0" width="{half}" height="{h}" fill="url(#bgR)"/>
|
||||||
|
<g filter="url(#glitch2)">
|
||||||
|
<circle cx="{half + half//2}" cy="{h//2 - 30}" r="35" fill="{ERROR}30" stroke="{ERROR}" stroke-width="2"/>
|
||||||
|
<circle cx="{half + half//2 - 50}" cy="{h//2 - 80}" r="25" fill="{ERROR}20" stroke="{ERROR}" stroke-width="1.5"/>
|
||||||
|
<circle cx="{half + half//2 + 55}" cy="{h//2 - 75}" r="22" fill="{ERROR}20" stroke="{ERROR}" stroke-width="1.5"/>
|
||||||
|
</g>
|
||||||
|
<text x="{half + half//2}" y="{h//2 + 60}" font-family="system-ui, sans-serif" font-size="20" font-weight="600" fill="{ERROR}" text-anchor="middle">AI Clone</text>
|
||||||
|
<rect x="{half}" y="{h - 50}" width="{half}" height="50" fill="{ERROR}22"/>
|
||||||
|
<text x="{half + half//2}" y="{h - 22}" font-family="system-ui, sans-serif" font-size="13" fill="{TEXT_MUTED}" text-anchor="middle">Synthetic voice clone</text>
|
||||||
|
|
||||||
|
<text x="30" y="50" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="{TEXT_PRIMARY}">Your Family's Voice, Protected</text>
|
||||||
|
</svg>'''
|
||||||
|
return svg
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# META CREATIVE B: Dark Web
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def meta_b_1x1():
|
||||||
|
"""1:1 (1080x1080) — dark terminal HUD aesthetic"""
|
||||||
|
w, h = 1080, 1080
|
||||||
|
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgTerm" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#050a05"/>
|
||||||
|
<stop offset="100%" stop-color="#0a1a0a"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="{SUCCESS}"/>
|
||||||
|
<stop offset="100%" stop-color="#16a34a"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="{w}" height="{h}" fill="url(#bgTerm)"/>
|
||||||
|
|
||||||
|
<!-- Matrix-like grid lines -->
|
||||||
|
<g stroke="{SUCCESS}10" stroke-width="0.5">
|
||||||
|
<line x1="0" y1="100" x2="{w}" y2="100"/>
|
||||||
|
<line x1="0" y1="200" x2="{w}" y2="200"/>
|
||||||
|
<line x1="0" y1="300" x2="{w}" y2="300"/>
|
||||||
|
<line x1="0" y1="400" x2="{w}" y2="400"/>
|
||||||
|
<line x1="0" y1="500" x2="{w}" y2="500"/>
|
||||||
|
<line x1="0" y1="600" x2="{w}" y2="600"/>
|
||||||
|
<line x1="0" y1="700" x2="{w}" y2="700"/>
|
||||||
|
<line x1="0" y1="800" x2="{w}" y2="800"/>
|
||||||
|
<line x1="0" y1="900" x2="{w}" y2="900"/>
|
||||||
|
<line x1="0" y1="1000" x2="{w}" y2="1000"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Terminal window frame -->
|
||||||
|
<rect x="100" y="200" width="{w - 200}" height="500" rx="12" fill="#0d1f0d" stroke="{SUCCESS}30" stroke-width="1.5"/>
|
||||||
|
<rect x="100" y="200" width="{w - 200}" height="40" rx="12" fill="#143014"/>
|
||||||
|
<rect x="100" y="228" width="{w - 200}" height="12" fill="#143014"/>
|
||||||
|
<circle cx="130" cy="220" r="6" fill="{ERROR}"/>
|
||||||
|
<circle cx="155" cy="220" r="6" fill="{WARNING}"/>
|
||||||
|
<circle cx="180" cy="220" r="6" fill="{SUCCESS}"/>
|
||||||
|
<text x="200" y="225" font-family="monospace" font-size="14" fill="{TEXT_MUTED}">darkwatch@shieldai:~$</text>
|
||||||
|
|
||||||
|
<!-- Terminal content -->
|
||||||
|
<text x="130" y="280" font-family="monospace" font-size="16" fill="{WARNING}">> Scanning 150+ dark web marketplaces...</text>
|
||||||
|
<text x="130" y="320" font-family="monospace" font-size="16" fill="{WARNING}">> Analyzing breach databases...</text>
|
||||||
|
|
||||||
|
<text x="130" y="380" font-family="monospace" font-size="18" font-weight="bold" fill="{ERROR}">! ALERT: MATCHES FOUND</text>
|
||||||
|
|
||||||
|
<rect x="130" y="410" width="320" height="28" fill="{ERROR}15"/>
|
||||||
|
<text x="140" y="430" font-family="monospace" font-size="15" fill="{ERROR}">email:***@gmail.com — 3 breaches</text>
|
||||||
|
|
||||||
|
<rect x="130" y="445" width="320" height="28" fill="{ERROR}15"/>
|
||||||
|
<text x="140" y="465" font-family="monospace" font-size="15" fill="{ERROR}">phone:+1 (555) ***-8842 — 2 breaches</text>
|
||||||
|
|
||||||
|
<rect x="130" y="480" width="320" height="28" fill="{ERROR}15"/>
|
||||||
|
<text x="140" y="500" font-family="monospace" font-size="15" fill="{ERROR}">ssn:***-**-6781 — 1 breach</text>
|
||||||
|
|
||||||
|
<text x="130" y="550" font-family="monospace" font-size="16" fill="{SUCCESS}">> Total exposures found: 5,284</text>
|
||||||
|
<text x="130" y="580" font-family="monospace" font-size="16" fill="{ACCENT_CYAN}">> Run scan on your data? [Y/n] _</text>
|
||||||
|
|
||||||
|
<!-- Bottom CTA -->
|
||||||
|
<rect x="340" y="750" width="400" height="56" rx="28" fill="{SUCCESS}"/>
|
||||||
|
<text x="540" y="785" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#050a05" text-anchor="middle">Scan Your Email Free</text>
|
||||||
|
|
||||||
|
<text x="{w//2}" y="860" font-family="system-ui, sans-serif" font-size="16" fill="{TEXT_MUTED}" text-anchor="middle">ShieldAI DarkWatch — 24/7 Dark Web Monitoring</text>
|
||||||
|
|
||||||
|
<text x="{w//2}" y="920" font-family="system-ui, sans-serif" font-size="28" font-weight="700" fill="{TEXT_PRIMARY}" text-anchor="middle">5K+ Exposures Found.</text>
|
||||||
|
<text x="{w//2}" y="960" font-family="system-ui, sans-serif" font-size="28" font-weight="700" fill="{SUCCESS}" text-anchor="middle">What About Yours?</text>
|
||||||
|
</svg>'''
|
||||||
|
return svg
|
||||||
|
|
||||||
|
|
||||||
|
def meta_b_45():
|
||||||
|
"""4:5 (1080x1350) — dark terminal HUD"""
|
||||||
|
w, h = 1080, 1350
|
||||||
|
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgB45" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#050a05"/>
|
||||||
|
<stop offset="100%" stop-color="#0a1a0a"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="{SUCCESS}"/>
|
||||||
|
<stop offset="100%" stop-color="#16a34a"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="{w}" height="{h}" fill="url(#bgB45)"/>
|
||||||
|
|
||||||
|
<!-- Terminal -->
|
||||||
|
<rect x="80" y="250" width="{w - 160}" height="520" rx="12" fill="#0d1f0d" stroke="{SUCCESS}30" stroke-width="1.5"/>
|
||||||
|
<rect x="80" y="250" width="{w - 160}" height="40" rx="12" fill="#143014"/>
|
||||||
|
<rect x="80" y="278" width="{w - 160}" height="12" fill="#143014"/>
|
||||||
|
<circle cx="110" cy="270" r="6" fill="{ERROR}"/>
|
||||||
|
<circle cx="135" cy="270" r="6" fill="{WARNING}"/>
|
||||||
|
<circle cx="160" cy="270" r="6" fill="{SUCCESS}"/>
|
||||||
|
<text x="180" y="275" font-family="monospace" font-size="14" fill="{TEXT_MUTED}">darkwatch@shieldai:~$</text>
|
||||||
|
|
||||||
|
<text x="110" y="330" font-family="monospace" font-size="16" fill="{WARNING}">> Scanning 150+ dark web marketplaces...</text>
|
||||||
|
<text x="110" y="360" font-family="monospace" font-size="16" fill="{WARNING}">> Cross-referencing databases...</text>
|
||||||
|
|
||||||
|
<text x="110" y="415" font-family="monospace" font-size="18" font-weight="bold" fill="{ERROR}">! ALERT: DATA EXPOSED</text>
|
||||||
|
|
||||||
|
<rect x="110" y="445" width="350" height="28" fill="{ERROR}15"/>
|
||||||
|
<text x="120" y="465" font-family="monospace" font-size="14" fill="{ERROR}">email:***@gmail.com — 3 breaches</text>
|
||||||
|
|
||||||
|
<rect x="110" y="480" width="350" height="28" fill="{ERROR}15"/>
|
||||||
|
<text x="120" y="500" font-family="monospace" font-size="14" fill="{ERROR}">phone:+1 (555) ***-8842 — 2 breaches</text>
|
||||||
|
|
||||||
|
<rect x="110" y="515" width="350" height="28" fill="{ERROR}15"/>
|
||||||
|
<text x="120" y="535" font-family="monospace" font-size="14" fill="{ERROR}">ssn:***-**-6781 — 1 breach</text>
|
||||||
|
|
||||||
|
<rect x="110" y="550" width="350" height="28" fill="{ERROR}15"/>
|
||||||
|
<text x="120" y="570" font-family="monospace" font-size="14" fill="{ERROR}">Address:*** Oak St — 1 breach</text>
|
||||||
|
|
||||||
|
<text x="110" y="625" font-family="monospace" font-size="16" fill="{SUCCESS}">> Total exposures monitored: 5,284</text>
|
||||||
|
<text x="110" y="660" font-family="monospace" font-size="16" fill="{ACCENT_CYAN}">> Run scan on your data? [Y/n] _</text>
|
||||||
|
|
||||||
|
<text x="{w//2}" y="840" font-family="system-ui, sans-serif" font-size="30" font-weight="700" fill="{TEXT_PRIMARY}" text-anchor="middle">Your Data May Already Be</text>
|
||||||
|
<text x="{w//2}" y="885" font-family="system-ui, sans-serif" font-size="30" font-weight="700" fill="{ERROR}" text-anchor="middle">For Sale on the Dark Web</text>
|
||||||
|
|
||||||
|
<text x="{w//2}" y="940" font-family="system-ui, sans-serif" font-size="16" fill="{TEXT_SECONDARY}" text-anchor="middle">ShieldAI scans 150+ marketplaces 24/7 and alerts you instantly</text>
|
||||||
|
|
||||||
|
<rect x="{w//2 - 175}" y="1000" width="350" height="56" rx="28" fill="{SUCCESS}"/>
|
||||||
|
<text x="{w//2}" y="1035" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#050a05" text-anchor="middle">Scan Your Email Free</text>
|
||||||
|
|
||||||
|
<text x="{w//2}" y="{h - 50}" font-family="system-ui, sans-serif" font-size="14" fill="{TEXT_MUTED}" text-anchor="middle">ShieldAI — AI-Powered Identity Protection for Everyone</text>
|
||||||
|
</svg>'''
|
||||||
|
return svg
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# META CREATIVE C: 3 Protections
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def meta_c_1x1():
|
||||||
|
"""1:1 (1080x1080) — three-panel layout"""
|
||||||
|
w, h = 1080, 1080
|
||||||
|
panel_w, panel_h = 300, 400
|
||||||
|
gap = 30
|
||||||
|
total_w = 3 * panel_w + 2 * gap
|
||||||
|
start_x = (w - total_w) // 2
|
||||||
|
top_y = 280
|
||||||
|
|
||||||
|
panels = [
|
||||||
|
("VoicePrint", ACCENT_CYAN, "AI Voice Clone\nDetection", "Real-time detection\nof synthetic voices\nwith 99.7% accuracy"),
|
||||||
|
("DarkWatch", ACCENT_BLUE, "Dark Web\nMonitoring", "24/7 scanning of\n150+ marketplaces\nfor your data"),
|
||||||
|
("SpamShield", SUCCESS, "Spam Call &\nText Blocking", "AI-powered filtering\nof spam calls\nand text messages"),
|
||||||
|
]
|
||||||
|
|
||||||
|
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgC" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="{DARK_BG}"/>
|
||||||
|
<stop offset="100%" stop-color="#050812"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="{ACCENT_BLUE}"/>
|
||||||
|
<stop offset="100%" stop-color="{ACCENT_CYAN}"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="{w}" height="{h}" fill="url(#bgC)"/>
|
||||||
|
{brand_bar(w, 6)}
|
||||||
|
|
||||||
|
<text x="{w//2}" y="140" font-family="system-ui, sans-serif" font-size="42" font-weight="700" fill="{TEXT_PRIMARY}" text-anchor="middle">3 Ways ShieldAI Protects Your Family</text>
|
||||||
|
<text x="{w//2}" y="185" font-family="system-ui, sans-serif" font-size="18" fill="{TEXT_SECONDARY}" text-anchor="middle">VoicePrint + DarkWatch + SpamShield</text>'''
|
||||||
|
|
||||||
|
for i, (name, color, title, desc) in enumerate(panels):
|
||||||
|
px = start_x + i * (panel_w + gap)
|
||||||
|
py = top_y
|
||||||
|
cx = px + panel_w // 2
|
||||||
|
icon_y = py + 60
|
||||||
|
|
||||||
|
svg += f'''
|
||||||
|
<rect x="{px}" y="{py}" width="{panel_w}" height="{panel_h}" rx="16" fill="{CARD_BG}" stroke="{color}30" stroke-width="1.5"/>
|
||||||
|
<circle cx="{cx}" cy="{icon_y}" r="40" fill="{color}22" stroke="{color}" stroke-width="2"/>
|
||||||
|
<text x="{cx}" y="{icon_y + 5}" font-family="system-ui, sans-serif" font-size="18" font-weight="700" fill="{color}" text-anchor="middle">{name}</text>'''
|
||||||
|
|
||||||
|
lines = title.split('\n')
|
||||||
|
for li, line in enumerate(lines):
|
||||||
|
svg += f'''
|
||||||
|
<text x="{cx}" y="{icon_y + 60 + li * 32}" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="{TEXT_PRIMARY}" text-anchor="middle">{line}</text>'''
|
||||||
|
|
||||||
|
desc_lines = desc.split('\n')
|
||||||
|
for li, line in enumerate(desc_lines):
|
||||||
|
svg += f'''
|
||||||
|
<text x="{cx}" y="{icon_y + 120 + li * 25}" font-family="system-ui, sans-serif" font-size="14" fill="{TEXT_SECONDARY}" text-anchor="middle">{line}</text>'''
|
||||||
|
|
||||||
|
svg += f'''
|
||||||
|
<rect x="{w//2 - 135}" y="760" width="270" height="52" rx="26" fill="{ACCENT_BLUE}"/>
|
||||||
|
<text x="{w//2}" y="793" font-family="system-ui, sans-serif" font-size="18" font-weight="600" fill="{TEXT_PRIMARY}" text-anchor="middle">Join the Waitlist</text>
|
||||||
|
|
||||||
|
<text x="{w//2}" y="870" font-family="system-ui, sans-serif" font-size="15" fill="{TEXT_MUTED}" text-anchor="middle">Three critical protections, one powerful platform</text>
|
||||||
|
<text x="{w//2}" y="900" font-family="system-ui, sans-serif" font-size="14" fill="{TEXT_MUTED}" text-anchor="middle">Start free. Launching soon.</text>
|
||||||
|
</svg>'''
|
||||||
|
return svg
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# META CREATIVE D: Family Protection
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def meta_d_base(w, h, small=False):
|
||||||
|
"""Family protection — multi-generational family with digital shield overlay"""
|
||||||
|
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="shieldGlow" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stop-color="{ACCENT_BLUE}30"/>
|
||||||
|
<stop offset="100%" stop-color="{ACCENT_BLUE}00"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgD" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="{DARK_BG}"/>
|
||||||
|
<stop offset="60%" stop-color="#0d1a30"/>
|
||||||
|
<stop offset="100%" stop-color="{DARK_BG}"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="{ACCENT_BLUE}"/>
|
||||||
|
<stop offset="100%" stop-color="{ACCENT_CYAN}"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shieldGradD" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="{ACCENT_BLUE}"/>
|
||||||
|
<stop offset="100%" stop-color="{ACCENT_CYAN}"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="{w}" height="{h}" fill="url(#bgD)"/>
|
||||||
|
{brand_bar(w, 5)}
|
||||||
|
|
||||||
|
<!-- Digital shield overlay -->
|
||||||
|
<circle cx="{w//2}" cy="{h//2}" r="{min(w,h)*0.38}" fill="url(#shieldGlow)"/>
|
||||||
|
<g transform="translate({w//2}, {h//2 - 30})">
|
||||||
|
<path d="M-60,-55 L60,-55 L65,15 Q65,55 35,75 L0,90 L-35,75 Q-65,55 -65,15 Z" fill="none" stroke="url(#shieldGradD)" stroke-width="3" opacity="0.8"/>
|
||||||
|
<path d="M-25,-5 L0,25 L30,-15" fill="none" stroke="{SUCCESS}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Family figures (simplified) -->
|
||||||
|
{g_family_figures(w, h)}
|
||||||
|
|
||||||
|
<text x="{w//2}" y="{h - 160}" font-family="system-ui, sans-serif" font-size="32" font-weight="700" fill="{TEXT_PRIMARY}" text-anchor="middle">Protect Your Whole Family</text>
|
||||||
|
<text x="{w//2}" y="{h - 115}" font-family="system-ui, sans-serif" font-size="17" fill="{TEXT_SECONDARY}" text-anchor="middle">AI voice clone detection + dark web monitoring + spam blocking</text>
|
||||||
|
<text x="{w//2}" y="{h - 85}" font-family="system-ui, sans-serif" font-size="17" fill="{TEXT_SECONDARY}" text-anchor="middle">for up to unlimited family members on Premium</text>
|
||||||
|
|
||||||
|
<rect x="{w//2 - 115}" y="{h - 60}" width="230" height="46" rx="23" fill="{ACCENT_BLUE}"/>
|
||||||
|
<text x="{w//2}" y="{h - 33}" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="{TEXT_PRIMARY}" text-anchor="middle">Protect My Family</text>
|
||||||
|
</svg>'''
|
||||||
|
return svg
|
||||||
|
|
||||||
|
|
||||||
|
def g_family_figures(w, h):
|
||||||
|
"""Generate simple family figure silhouettes."""
|
||||||
|
cx = w // 2
|
||||||
|
base_y = h // 2 + 60
|
||||||
|
return f'''
|
||||||
|
<!-- Grandparent L -->
|
||||||
|
<circle cx="{cx - 110}" cy="{base_y - 55}" r="22" fill="#33415580"/>
|
||||||
|
<rect x="{cx - 125}" y="{base_y - 30}" width="30" height="50" rx="8" fill="#33415560"/>
|
||||||
|
|
||||||
|
<!-- Parent L -->
|
||||||
|
<circle cx="{cx - 50}" cy="{base_y - 70}" r="25" fill="#47556980"/>
|
||||||
|
<rect x="{cx - 68}" y="{base_y - 42}" width="36" height="65" rx="10" fill="#47556960"/>
|
||||||
|
|
||||||
|
<!-- Child -->
|
||||||
|
<circle cx="{cx + 15}" cy="{base_y - 60}" r="18" fill="#64748b80"/>
|
||||||
|
<rect x="{cx + 2}" y="{base_y - 40}" width="26" height="40" rx="8" fill="#64748b60"/>
|
||||||
|
|
||||||
|
<!-- Parent R -->
|
||||||
|
<circle cx="{cx + 80}" cy="{base_y - 70}" r="25" fill="#47556980"/>
|
||||||
|
<rect x="{cx + 62}" y="{base_y - 42}" width="36" height="65" rx="10" fill="#47556960"/>
|
||||||
|
|
||||||
|
<!-- Grandparent R -->
|
||||||
|
<circle cx="{cx + 140}" cy="{base_y - 55}" r="22" fill="#33415580"/>
|
||||||
|
<rect x="{cx + 125}" y="{base_y - 30}" width="30" height="50" rx="8" fill="#33415560"/>
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def meta_d_1x1():
|
||||||
|
return meta_d_base(1080, 1080)
|
||||||
|
|
||||||
|
def meta_d_191():
|
||||||
|
return meta_d_base(1200, 628)
|
||||||
|
|
||||||
|
def meta_d_45():
|
||||||
|
return meta_d_base(1080, 1350)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# GENERATE ALL
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
assets = [
|
||||||
|
# Google Display
|
||||||
|
("gd_square_1200x1200.svg", gd_square()),
|
||||||
|
("gd_landscape_1200x628.svg", gd_landscape()),
|
||||||
|
("gd_portrait_600x750.svg", gd_portrait()),
|
||||||
|
# Meta Creative A
|
||||||
|
("meta_a_1x1_1080x1080.svg", meta_a_1x1()),
|
||||||
|
("meta_a_191_1200x628.svg", meta_a_191()),
|
||||||
|
# Meta Creative B
|
||||||
|
("meta_b_1x1_1080x1080.svg", meta_b_1x1()),
|
||||||
|
("meta_b_45_1080x1350.svg", meta_b_45()),
|
||||||
|
# Meta Creative C
|
||||||
|
("meta_c_1x1_1080x1080.svg", meta_c_1x1()),
|
||||||
|
# Meta Creative D
|
||||||
|
("meta_d_1x1_1080x1080.svg", meta_d_1x1()),
|
||||||
|
("meta_d_191_1200x628.svg", meta_d_191()),
|
||||||
|
("meta_d_45_1080x1350.svg", meta_d_45()),
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, svg in assets:
|
||||||
|
path = os.path.join(OUT, name)
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
f.write(svg)
|
||||||
|
print(f"Created: {name} ({len(svg)} bytes)")
|
||||||
|
|
||||||
|
print(f"\nDone. Generated {len(assets)} SVG files in {OUT}")
|
||||||
BIN
assets/ads/linkedin/variant1_professional.jpg
Normal file
|
After Width: | Height: | Size: 99 KiB |
120
assets/ads/linkedin/variant1_professional.svg
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 627" width="1200" height="627">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#0a0f1e"/>
|
||||||
|
<stop offset="50%" stop-color="#111827"/>
|
||||||
|
<stop offset="100%" stop-color="#0f1729"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6"/>
|
||||||
|
<stop offset="100%" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shieldGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6"/>
|
||||||
|
<stop offset="100%" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="phoneGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#1e293b"/>
|
||||||
|
<stop offset="100%" stop-color="#0f172a"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="glow">
|
||||||
|
<feGaussianBlur stdDeviation="3" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
<filter id="softGlow">
|
||||||
|
<feGaussianBlur stdDeviation="8" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background -->
|
||||||
|
<rect width="1200" height="627" fill="url(#bg)"/>
|
||||||
|
|
||||||
|
<!-- Grid pattern -->
|
||||||
|
<g opacity="0.05" stroke="#3b82f6" stroke-width="0.5">
|
||||||
|
<line x1="0" y1="100" x2="1200" y2="100"/>
|
||||||
|
<line x1="0" y1="200" x2="1200" y2="200"/>
|
||||||
|
<line x1="0" y1="300" x2="1200" y2="300"/>
|
||||||
|
<line x1="0" y1="400" x2="1200" y2="400"/>
|
||||||
|
<line x1="0" y1="500" x2="1200" y2="500"/>
|
||||||
|
<line x1="200" y1="0" x2="200" y2="627"/>
|
||||||
|
<line x1="400" y1="0" x2="400" y2="627"/>
|
||||||
|
<line x1="600" y1="0" x2="600" y2="627"/>
|
||||||
|
<line x1="800" y1="0" x2="800" y2="627"/>
|
||||||
|
<line x1="1000" y1="0" x2="1000" y2="627"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Decorative circle top-right -->
|
||||||
|
<circle cx="1050" cy="100" r="300" fill="#3b82f6" opacity="0.04"/>
|
||||||
|
<circle cx="1100" cy="50" r="200" fill="#06b6d4" opacity="0.03"/>
|
||||||
|
|
||||||
|
<!-- Left content area -->
|
||||||
|
<!-- Headline -->
|
||||||
|
<text x="60" y="200" font-family="DejaVu Sans, sans-serif" font-size="36" font-weight="bold" fill="#f1f5f9">
|
||||||
|
<tspan x="60" dy="0">AI Voice Cloning</tspan>
|
||||||
|
<tspan x="60" dy="48" fill="#3b82f6">Is the New Phishing Threat</tspan>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<!-- Body copy -->
|
||||||
|
<text x="60" y="330" font-family="DejaVu Sans, sans-serif" font-size="16" fill="#94a3b8">
|
||||||
|
<tspan x="60" dy="0">Cybercriminals are using AI-generated voice clones</tspan>
|
||||||
|
<tspan x="60" dy="28">to impersonate executives and family members.</tspan>
|
||||||
|
<tspan x="60" dy="28">ShieldAI detects synthetic voices in real time.</tspan>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<rect x="60" y="420" width="180" height="50" rx="25" fill="url(#accent)"/>
|
||||||
|
<text x="150" y="452" font-family="DejaVu Sans, sans-serif" font-size="16" font-weight="bold" fill="#ffffff" text-anchor="middle">Learn More →</text>
|
||||||
|
|
||||||
|
<!-- Right side: Visual area -->
|
||||||
|
<!-- Large shield background glow -->
|
||||||
|
<circle cx="800" cy="330" r="180" fill="#3b82f6" opacity="0.06" filter="url(#softGlow)"/>
|
||||||
|
|
||||||
|
<!-- Shield icon -->
|
||||||
|
<g transform="translate(680, 200)">
|
||||||
|
<path d="M120 20 L220 60 L220 110 Q220 170 120 210 Q20 170 20 110 L20 60 Z" fill="url(#shieldGrad)" opacity="0.9"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Phone silhouette -->
|
||||||
|
<g transform="translate(710, 260)">
|
||||||
|
<rect x="0" y="0" width="50" height="90" rx="10" fill="url(#phoneGrad)" stroke="#334155" stroke-width="1.5"/>
|
||||||
|
<rect x="15" y="8" width="20" height="3" rx="1.5" fill="#3b82f6" opacity="0.5"/>
|
||||||
|
<circle cx="25" cy="68" r="8" fill="none" stroke="#334155" stroke-width="1"/>
|
||||||
|
<line x1="10" y1="20" x2="40" y2="20" stroke="#334155" stroke-width="0.5"/>
|
||||||
|
<line x1="10" y1="25" x2="35" y2="25" stroke="#334155" stroke-width="0.5"/>
|
||||||
|
<line x1="10" y1="30" x2="30" y2="30" stroke="#334155" stroke-width="0.5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Sound wave lines from phone -->
|
||||||
|
<g stroke="#3b82f6" stroke-width="2" fill="none" opacity="0.5" filter="url(#glow)">
|
||||||
|
<path d="M770 290 Q790 280 770 270"/>
|
||||||
|
<path d="M780 300 Q810 285 780 270"/>
|
||||||
|
<path d="M790 310 Q830 290 790 270"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Executive silhouette -->
|
||||||
|
<g transform="translate(820, 230)" opacity="0.15">
|
||||||
|
<ellipse cx="40" cy="25" rx="25" ry="25" fill="#f1f5f9"/>
|
||||||
|
<rect x="0" y="50" width="80" height="100" rx="10" fill="#f1f5f9"/>
|
||||||
|
<rect x="-5" y="60" width="15" height="60" rx="5" fill="#f1f5f9"/>
|
||||||
|
<rect x="70" y="60" width="15" height="60" rx="5" fill="#f1f5f9"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Digital shield overlay on right -->
|
||||||
|
<g transform="translate(750, 170)" opacity="0.12">
|
||||||
|
<path d="M50 0 L100 30 L100 70 Q100 120 50 150 Q0 120 0 70 L0 30 Z" fill="none" stroke="#3b82f6" stroke-width="3"/>
|
||||||
|
<path d="M50 0 L100 30 L100 70 Q100 120 50 150 Q0 120 0 70 L0 30 Z" fill="none" stroke="#06b6d4" stroke-width="1" transform="translate(5, 5)"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom branding bar -->
|
||||||
|
<rect x="0" y="577" width="1200" height="50" fill="#0a0f1e" opacity="0.8"/>
|
||||||
|
<line x1="0" y1="577" x2="1200" y2="577" stroke="#3b82f6" stroke-width="1" opacity="0.3"/>
|
||||||
|
|
||||||
|
<!-- ShieldAI logo -->
|
||||||
|
<g transform="translate(60, 590)">
|
||||||
|
<path d="M15 2 L28 12 L28 22 Q28 32 15 38 Q2 32 2 22 L2 12 Z" fill="url(#accent)" opacity="0.9"/>
|
||||||
|
<path d="M11 18 L15 18 L15 22 L19 22 L19 26 L15 26 L15 30 L11 30 L11 26 L7 26 L7 22 L11 22 Z" fill="white" opacity="0.9"/>
|
||||||
|
<text x="38" y="26" font-family="DejaVu Sans, sans-serif" font-size="16" font-weight="bold" fill="#f1f5f9">ShieldAI</text>
|
||||||
|
<text x="115" y="26" font-family="DejaVu Sans, sans-serif" font-size="11" fill="#64748b">AI-Powered Identity Protection for Everyone</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.6 KiB |
BIN
assets/ads/linkedin/variant2_datasecurity.jpg
Normal file
|
After Width: | Height: | Size: 111 KiB |
132
assets/ads/linkedin/variant2_datasecurity.svg
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 627" width="1200" height="627">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#05080f"/>
|
||||||
|
<stop offset="50%" stop-color="#0a0f1e"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6"/>
|
||||||
|
<stop offset="100%" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="dangerGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#ef4444"/>
|
||||||
|
<stop offset="100%" stop-color="#dc2626"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="redGlow">
|
||||||
|
<feGaussianBlur stdDeviation="4" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
<filter id="softGlow">
|
||||||
|
<feGaussianBlur stdDeviation="8" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background -->
|
||||||
|
<rect width="1200" height="627" fill="url(#bg)"/>
|
||||||
|
|
||||||
|
<!-- Terminal scan lines -->
|
||||||
|
<g opacity="0.03">
|
||||||
|
<line x1="0" y1="0" x2="1200" y2="0" stroke="#22c55e" stroke-width="1"/>
|
||||||
|
<line x1="0" y1="4" x2="1200" y2="4" stroke="#22c55e" stroke-width="0.5"/>
|
||||||
|
<line x1="0" y1="8" x2="1200" y2="8" stroke="#22c55e" stroke-width="1"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Matrix rain effect lines -->
|
||||||
|
<g stroke="#22c55e" stroke-width="0.5" opacity="0.04">
|
||||||
|
<line x1="100" y1="0" x2="100" y2="627"/>
|
||||||
|
<line x1="300" y1="0" x2="300" y2="627"/>
|
||||||
|
<line x1="500" y1="0" x2="500" y2="627"/>
|
||||||
|
<line x1="700" y1="0" x2="700" y2="627"/>
|
||||||
|
<line x1="900" y1="0" x2="900" y2="627"/>
|
||||||
|
<line x1="1100" y1="0" x2="1100" y2="627"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Top-right decorative glow -->
|
||||||
|
<circle cx="1050" cy="100" r="250" fill="#ef4444" opacity="0.03"/>
|
||||||
|
|
||||||
|
<!-- Terminal window - left side -->
|
||||||
|
<g transform="translate(60, 120)">
|
||||||
|
<rect x="0" y="0" width="520" height="340" rx="8" fill="#0d1117" stroke="#1e293b" stroke-width="1.5"/>
|
||||||
|
<!-- Window chrome -->
|
||||||
|
<rect x="0" y="0" width="520" height="32" rx="8" fill="#161b22"/>
|
||||||
|
<rect x="0" y="16" width="520" height="16" fill="#161b22"/>
|
||||||
|
<circle cx="20" cy="16" r="5" fill="#ef4444"/>
|
||||||
|
<circle cx="37" cy="16" r="5" fill="#eab308"/>
|
||||||
|
<circle cx="54" cy="16" r="5" fill="#22c55e"/>
|
||||||
|
<text x="260" y="21" font-family="monospace" font-size="11" fill="#64748b" text-anchor="middle">DarkWatch Terminal — Scan Results</text>
|
||||||
|
|
||||||
|
<!-- Terminal content -->
|
||||||
|
<text x="16" y="60" font-family="monospace" font-size="12" fill="#22c55e">$ ./darkwatch --scan --deep</text>
|
||||||
|
<text x="16" y="82" font-family="monospace" font-size="12" fill="#64748b">Scanning 178 dark web marketplaces...</text>
|
||||||
|
<text x="16" y="104" font-family="monospace" font-size="12" fill="#64748b">Checking credentials associated with target@email.com</text>
|
||||||
|
<text x="16" y="126" font-family="monospace" font-size="12" fill="#64748b">Checking phone: +1 (555) ***-****</text>
|
||||||
|
<text x="16" y="148" font-family="monospace" font-size="12" fill="#22c55e">Scan complete. Found 12 exposures.</text>
|
||||||
|
|
||||||
|
<!-- Alert box -->
|
||||||
|
<rect x="16" y="170" width="488" height="44" rx="4" fill="#450a0a" stroke="#ef4444" stroke-width="1" opacity="0.9"/>
|
||||||
|
<circle cx="32" cy="192" r="5" fill="#ef4444" filter="url(#redGlow)"/>
|
||||||
|
<text x="44" y="196" font-family="monospace" font-size="12" fill="#fca5a5" font-weight="bold">CRITICAL: Email + password exposed on 3 marketplaces</text>
|
||||||
|
|
||||||
|
<!-- Exposed data rows -->
|
||||||
|
<rect x="16" y="222" width="488" height="30" rx="2" fill="#1a2332" opacity="0.5"/>
|
||||||
|
<text x="24" y="241" font-family="monospace" font-size="11" fill="#94a3b8">email@example.com</text>
|
||||||
|
<text x="280" y="241" font-family="monospace" font-size="11" fill="#ef4444">P@ssw0rd123!</text>
|
||||||
|
<text x="460" y="241" font-family="monospace" font-size="11" fill="#f59e0b">LEAKED</text>
|
||||||
|
|
||||||
|
<rect x="16" y="255" width="488" height="30" rx="2" fill="#1a2332" opacity="0.3"/>
|
||||||
|
<text x="24" y="274" font-family="monospace" font-size="11" fill="#94a3b8">+1 (555) 234-5678</text>
|
||||||
|
<text x="280" y="274" font-family="monospace" font-size="11" fill="#ef4444">[HASHED]</text>
|
||||||
|
<text x="460" y="274" font-family="monospace" font-size="11" fill="#f59e0b">LEAKED</text>
|
||||||
|
|
||||||
|
<rect x="16" y="288" width="488" height="30" rx="2" fill="#1a2332" opacity="0.5"/>
|
||||||
|
<text x="24" y="307" font-family="monospace" font-size="11" fill="#94a3b8">SSN: ***-**-1234</text>
|
||||||
|
<text x="280" y="307" font-family="monospace" font-size="11" fill="#ef4444">[REDACTED]</text>
|
||||||
|
<text x="460" y="307" font-family="monospace" font-size="11" fill="#ef4444">HIGH RISK</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Right side: Headline & CTA -->
|
||||||
|
<text x="660" y="200" font-family="DejaVu Sans, sans-serif" font-size="36" font-weight="bold" fill="#f1f5f9">
|
||||||
|
<tspan x="660" dy="0">Your Personal Data</tspan>
|
||||||
|
<tspan x="660" dy="48" fill="#ef4444">Is on the Dark Web</tspan>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<text x="660" y="320" font-family="DejaVu Sans, sans-serif" font-size="16" fill="#94a3b8">
|
||||||
|
<tspan x="660" dy="0">70% of data breaches expose employee</tspan>
|
||||||
|
<tspan x="660" dy="28">personal contact info. ShieldAI's DarkWatch</tspan>
|
||||||
|
<tspan x="660" dy="28">scans 100+ marketplaces daily for</tspan>
|
||||||
|
<tspan x="660" dy="28">exposed emails, phones, and SSNs.</tspan>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<!-- Stats row -->
|
||||||
|
<g transform="translate(660, 400)">
|
||||||
|
<rect x="0" y="0" width="110" height="60" rx="8" fill="#1a2332" stroke="#1e293b" stroke-width="1"/>
|
||||||
|
<text x="55" y="25" font-family="DejaVu Sans, sans-serif" font-size="20" font-weight="bold" fill="#ef4444" text-anchor="middle">178</text>
|
||||||
|
<text x="55" y="48" font-family="DejaVu Sans, sans-serif" font-size="10" fill="#64748b" text-anchor="middle">Marketplaces</text>
|
||||||
|
|
||||||
|
<rect x="125" y="0" width="110" height="60" rx="8" fill="#1a2332" stroke="#1e293b" stroke-width="1"/>
|
||||||
|
<text x="180" y="25" font-family="DejaVu Sans, sans-serif" font-size="20" font-weight="bold" fill="#f59e0b" text-anchor="middle">24/7</text>
|
||||||
|
<text x="180" y="48" font-family="DejaVu Sans, sans-serif" font-size="10" fill="#64748b" text-anchor="middle">Monitoring</text>
|
||||||
|
|
||||||
|
<rect x="250" y="0" width="110" height="60" rx="8" fill="#1a2332" stroke="#1e293b" stroke-width="1"/>
|
||||||
|
<text x="305" y="25" font-family="DejaVu Sans, sans-serif" font-size="20" font-weight="bold" fill="#22c55e" text-anchor="middle">99.7%</text>
|
||||||
|
<text x="305" y="48" font-family="DejaVu Sans, sans-serif" font-size="10" fill="#64748b" text-anchor="middle">Accuracy</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<rect x="660" y="490" width="200" height="50" rx="25" fill="url(#accent)"/>
|
||||||
|
<text x="760" y="522" font-family="DejaVu Sans, sans-serif" font-size="16" font-weight="bold" fill="#ffffff" text-anchor="middle">Monitor Your Data →</text>
|
||||||
|
|
||||||
|
<!-- Bottom branding bar -->
|
||||||
|
<rect x="0" y="577" width="1200" height="50" fill="#05080f" opacity="0.9"/>
|
||||||
|
<line x1="0" y1="577" x2="1200" y2="577" stroke="#3b82f6" stroke-width="1" opacity="0.3"/>
|
||||||
|
|
||||||
|
<!-- ShieldAI logo -->
|
||||||
|
<g transform="translate(60, 590)">
|
||||||
|
<path d="M15 2 L28 12 L28 22 Q28 32 15 38 Q2 32 2 22 L2 12 Z" fill="url(#accent)" opacity="0.9"/>
|
||||||
|
<path d="M11 18 L15 18 L15 22 L19 22 L19 26 L15 26 L15 30 L11 30 L11 26 L7 26 L7 22 L11 22 Z" fill="white" opacity="0.9"/>
|
||||||
|
<text x="38" y="26" font-family="DejaVu Sans, sans-serif" font-size="16" font-weight="bold" fill="#f1f5f9">ShieldAI</text>
|
||||||
|
<text x="115" y="26" font-family="DejaVu Sans, sans-serif" font-size="11" fill="#64748b">AI-Powered Identity Protection for Everyone</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.5 KiB |
BIN
assets/ads/linkedin/variant3_family_professional.jpg
Normal file
|
After Width: | Height: | Size: 93 KiB |
162
assets/ads/linkedin/variant3_family_professional.svg
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 627" width="1200" height="627">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgLeft" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#0a0f1e"/>
|
||||||
|
<stop offset="100%" stop-color="#111827"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="bgRight" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#1a0f0a"/>
|
||||||
|
<stop offset="100%" stop-color="#2d1a10"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6"/>
|
||||||
|
<stop offset="100%" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="warmAccent" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#f59e0b"/>
|
||||||
|
<stop offset="100%" stop-color="#f97316"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="dividerGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0"/>
|
||||||
|
<stop offset="50%" stop-color="#3b82f6" stop-opacity="0.3"/>
|
||||||
|
<stop offset="100%" stop-color="#f59e0b" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="softGlow">
|
||||||
|
<feGaussianBlur stdDeviation="6" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- LEFT HALF: Professional -->
|
||||||
|
|
||||||
|
<!-- Background left -->
|
||||||
|
<rect x="0" y="0" width="600" height="577" fill="url(#bgLeft)"/>
|
||||||
|
|
||||||
|
<!-- Subtle grid left -->
|
||||||
|
<g opacity="0.04" stroke="#3b82f6" stroke-width="0.5">
|
||||||
|
<line x1="0" y1="100" x2="600" y2="100"/>
|
||||||
|
<line x1="0" y1="200" x2="600" y2="200"/>
|
||||||
|
<line x1="0" y1="300" x2="600" y2="300"/>
|
||||||
|
<line x1="0" y1="400" x2="600" y2="400"/>
|
||||||
|
<line x1="0" y1="500" x2="600" y2="500"/>
|
||||||
|
<line x1="150" y1="0" x2="150" y2="577"/>
|
||||||
|
<line x1="300" y1="0" x2="300" y2="577"/>
|
||||||
|
<line x1="450" y1="0" x2="450" y2="577"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Office desk illustration -->
|
||||||
|
<g transform="translate(100, 160)" opacity="0.12">
|
||||||
|
<!-- Monitor -->
|
||||||
|
<rect x="30" y="20" width="100" height="65" rx="3" fill="#3b82f6"/>
|
||||||
|
<rect x="35" y="25" width="90" height="55" rx="1" fill="#0a0f1e"/>
|
||||||
|
<!-- Screen content -->
|
||||||
|
<rect x="40" y="35" width="40" height="3" rx="1" fill="#3b82f6" opacity="0.5"/>
|
||||||
|
<rect x="40" y="42" width="60" height="3" rx="1" fill="#3b82f6" opacity="0.3"/>
|
||||||
|
<rect x="40" y="49" width="25" height="3" rx="1" fill="#3b82f6" opacity="0.3"/>
|
||||||
|
<!-- Stand -->
|
||||||
|
<rect x="55" y="85" width="50" height="5" rx="1" fill="#1e293b"/>
|
||||||
|
<rect x="70" y="90" width="20" height="10" fill="#1e293b"/>
|
||||||
|
<!-- Desk -->
|
||||||
|
<rect x="0" y="100" width="180" height="5" rx="1" fill="#1e293b"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Professional icon label -->
|
||||||
|
<g transform="translate(60, 130)">
|
||||||
|
<circle cx="20" cy="20" r="20" fill="#3b82f6" opacity="0.15"/>
|
||||||
|
<path d="M12 28 L12 20 L20 16 L28 20 L28 28 Z" fill="#3b82f6" opacity="0.8"/>
|
||||||
|
<text x="50" y="25" font-family="DejaVu Sans, sans-serif" font-size="14" font-weight="bold" fill="#3b82f6">Work Protection</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Professional features -->
|
||||||
|
<g transform="translate(60, 280)" opacity="0.7">
|
||||||
|
<circle cx="8" cy="8" r="4" fill="#22c55e"/>
|
||||||
|
<text x="20" y="13" font-family="DejaVu Sans, sans-serif" font-size="13" fill="#94a3b8">AI voice clone detection</text>
|
||||||
|
<circle cx="8" cy="33" r="4" fill="#22c55e"/>
|
||||||
|
<text x="20" y="38" font-family="DejaVu Sans, sans-serif" font-size="13" fill="#94a3b8">Dark web monitoring</text>
|
||||||
|
<circle cx="8" cy="58" r="4" fill="#22c55e"/>
|
||||||
|
<text x="20" y="63" font-family="DejaVu Sans, sans-serif" font-size="13" fill="#94a3b8">Spam call/text blocking</text>
|
||||||
|
<circle cx="8" cy="83" r="4" fill="#22c55e"/>
|
||||||
|
<text x="20" y="88" font-family="DejaVu Sans, sans-serif" font-size="13" fill="#94a3b8">Enterprise-grade security</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- RIGHT HALF: Family -->
|
||||||
|
|
||||||
|
<!-- Background right -->
|
||||||
|
<rect x="600" y="0" width="600" height="577" fill="url(#bgRight)"/>
|
||||||
|
|
||||||
|
<!-- Warm glow background -->
|
||||||
|
<circle cx="850" cy="250" r="200" fill="#f59e0b" opacity="0.04"/>
|
||||||
|
|
||||||
|
<!-- Family illustration -->
|
||||||
|
<g transform="translate(730, 180)" opacity="0.12">
|
||||||
|
<!-- Adult 1 -->
|
||||||
|
<ellipse cx="40" cy="20" rx="18" ry="18" fill="#f59e0b"/>
|
||||||
|
<rect x="15" y="38" width="50" height="65" rx="8" fill="#f59e0b"/>
|
||||||
|
<!-- Adult 2 -->
|
||||||
|
<ellipse cx="120" cy="20" rx="18" ry="18" fill="#f59e0b"/>
|
||||||
|
<rect x="95" y="38" width="50" height="65" rx="8" fill="#f59e0b"/>
|
||||||
|
<!-- Child 1 -->
|
||||||
|
<ellipse cx="80" cy="55" rx="14" ry="14" fill="#f59e0b"/>
|
||||||
|
<rect x="64" y="69" width="32" height="40" rx="6" fill="#f59e0b"/>
|
||||||
|
<!-- Child 2 -->
|
||||||
|
<ellipse cx="160" cy="55" rx="14" ry="14" fill="#f97316"/>
|
||||||
|
<rect x="144" y="69" width="32" height="35" rx="6" fill="#f97316"/>
|
||||||
|
<!-- Shield over all -->
|
||||||
|
<path d="M80 10 L160 40 L160 75 Q160 110 80 135 Q0 110 0 75 L0 40 Z" fill="none" stroke="#f59e0b" stroke-width="2" opacity="0.5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Family icon label -->
|
||||||
|
<g transform="translate(630, 130)">
|
||||||
|
<circle cx="20" cy="20" r="20" fill="#f59e0b" opacity="0.15"/>
|
||||||
|
<path d="M12 16 A4 4 0 1 1 12 24 A4 4 0 1 1 12 16z" fill="#f59e0b" opacity="0.8"/>
|
||||||
|
<path d="M8 25 Q12 30 20 30 Q28 30 32 25" fill="#f59e0b" opacity="0.8"/>
|
||||||
|
<text x="50" y="25" font-family="DejaVu Sans, sans-serif" font-size="14" font-weight="bold" fill="#f59e0b">Family Safety</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Family features -->
|
||||||
|
<g transform="translate(630, 280)" opacity="0.7">
|
||||||
|
<circle cx="8" cy="8" r="4" fill="#22c55e"/>
|
||||||
|
<text x="20" y="13" font-family="DejaVu Sans, sans-serif" font-size="13" fill="#d4a574">Unlimited family members</text>
|
||||||
|
<circle cx="8" cy="33" r="4" fill="#22c55e"/>
|
||||||
|
<text x="20" y="38" font-family="DejaVu Sans, sans-serif" font-size="13" fill="#d4a574">Senior scam protection</text>
|
||||||
|
<circle cx="8" cy="58" r="4" fill="#22c55e"/>
|
||||||
|
<text x="20" y="63" font-family="DejaVu Sans, sans-serif" font-size="13" fill="#d4a574">Real-time alerts to family</text>
|
||||||
|
<circle cx="8" cy="83" r="4" fill="#22c55e"/>
|
||||||
|
<text x="20" y="88" font-family="DejaVu Sans, sans-serif" font-size="13" fill="#d4a574">24/7 support for all members</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Center divider -->
|
||||||
|
<line x1="600" y1="50" x2="600" y2="527" stroke="url(#dividerGrad)" stroke-width="2"/>
|
||||||
|
|
||||||
|
<!-- Unified by ShieldAI badge -->
|
||||||
|
<g transform="translate(380, 370)">
|
||||||
|
<rect x="0" y="0" width="440" height="60" rx="30" fill="#1a2332" stroke="#334155" stroke-width="1" opacity="0.9"/>
|
||||||
|
<path d="M25 15 L45 28 L45 40 Q45 50 25 55 Q5 50 5 40 L5 28 Z" fill="url(#accent)" opacity="0.9"/>
|
||||||
|
<path d="M19 30 L23 30 L23 34 L27 34 L27 38 L23 38 L23 42 L19 42 L19 38 L15 38 L15 34 L19 34 Z" fill="white" opacity="0.9"/>
|
||||||
|
<text x="55" y="35" font-family="DejaVu Sans, sans-serif" font-size="18" font-weight="bold" fill="#f1f5f9">Unified by</text>
|
||||||
|
<text x="155" y="35" font-family="DejaVu Sans, sans-serif" font-size="18" font-weight="bold" fill="#3b82f6">ShieldAI</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Headline at center-top -->
|
||||||
|
<text x="600" y="100" font-family="DejaVu Sans, sans-serif" font-size="34" font-weight="bold" fill="#f1f5f9" text-anchor="middle">
|
||||||
|
<tspan x="600" dy="0">One Platform.</tspan>
|
||||||
|
<tspan x="600" dy="44" fill="#3b82f6">Work Protection +</tspan>
|
||||||
|
<tspan x="600" dy="44" fill="#f59e0b">Family Safety.</tspan>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<rect x="460" y="500" width="280" height="50" rx="25" fill="url(#accent)"/>
|
||||||
|
<text x="600" y="532" font-family="DejaVu Sans, sans-serif" font-size="16" font-weight="bold" fill="#ffffff" text-anchor="middle">Join 1,000+ Early Adopters →</text>
|
||||||
|
|
||||||
|
<!-- Bottom branding bar -->
|
||||||
|
<rect x="0" y="577" width="1200" height="50" fill="#05080f" opacity="0.9"/>
|
||||||
|
<line x1="0" y1="577" x2="1200" y2="577" stroke="url(#accent)" stroke-width="1" opacity="0.3"/>
|
||||||
|
|
||||||
|
<!-- ShieldAI logo -->
|
||||||
|
<g transform="translate(60, 590)">
|
||||||
|
<path d="M15 2 L28 12 L28 22 Q28 32 15 38 Q2 32 2 22 L2 12 Z" fill="url(#accent)" opacity="0.9"/>
|
||||||
|
<path d="M11 18 L15 18 L15 22 L19 22 L19 26 L15 26 L15 30 L11 30 L11 26 L7 26 L7 22 L11 22 Z" fill="white" opacity="0.9"/>
|
||||||
|
<text x="38" y="26" font-family="DejaVu Sans, sans-serif" font-size="16" font-weight="bold" fill="#f1f5f9">ShieldAI</text>
|
||||||
|
<text x="115" y="26" font-family="DejaVu Sans, sans-serif" font-size="11" fill="#64748b">AI-Powered Identity Protection for Everyone</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/ads/meta_a_191_1200x628.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
40
assets/ads/meta_a_191_1200x628.svg
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="628" viewBox="0 0 1200 628">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgL" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#0a1528"/>
|
||||||
|
<stop offset="100%" stop-color="#0f1d35"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="bgR" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#1a0a0a"/>
|
||||||
|
<stop offset="100%" stop-color="#2d0f0f"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6"/>
|
||||||
|
<stop offset="100%" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="glitch2">
|
||||||
|
<feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="2" result="noise"/>
|
||||||
|
<feDisplacementMap in="SourceGraphic" in2="noise" scale="6" xChannelSelector="R" yChannelSelector="G"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="600" height="628" fill="url(#bgL)"/>
|
||||||
|
<circle cx="300" cy="284" r="35" fill="#3b82f630" stroke="#3b82f6" stroke-width="2"/>
|
||||||
|
<circle cx="250" cy="234" r="25" fill="#3b82f620" stroke="#3b82f6" stroke-width="1.5"/>
|
||||||
|
<circle cx="355" cy="239" r="22" fill="#3b82f620" stroke="#3b82f6" stroke-width="1.5"/>
|
||||||
|
<text x="300" y="374" font-family="system-ui, sans-serif" font-size="20" font-weight="600" fill="#f1f5f9" text-anchor="middle">Your Family</text>
|
||||||
|
<rect x="0" y="578" width="600" height="50" fill="#1a2332"/>
|
||||||
|
<text x="300" y="606" font-family="system-ui, sans-serif" font-size="13" fill="#94a3b8" text-anchor="middle">Real voice, real moment</text>
|
||||||
|
|
||||||
|
<rect x="599" y="0" width="3" height="628" fill="#1e293b"/>
|
||||||
|
<rect x="600" y="0" width="600" height="628" fill="url(#bgR)"/>
|
||||||
|
<g filter="url(#glitch2)">
|
||||||
|
<circle cx="900" cy="284" r="35" fill="#ef444430" stroke="#ef4444" stroke-width="2"/>
|
||||||
|
<circle cx="850" cy="234" r="25" fill="#ef444420" stroke="#ef4444" stroke-width="1.5"/>
|
||||||
|
<circle cx="955" cy="239" r="22" fill="#ef444420" stroke="#ef4444" stroke-width="1.5"/>
|
||||||
|
</g>
|
||||||
|
<text x="900" y="374" font-family="system-ui, sans-serif" font-size="20" font-weight="600" fill="#ef4444" text-anchor="middle">AI Clone</text>
|
||||||
|
<rect x="600" y="578" width="600" height="50" fill="#ef444422"/>
|
||||||
|
<text x="900" y="606" font-family="system-ui, sans-serif" font-size="13" fill="#64748b" text-anchor="middle">Synthetic voice clone</text>
|
||||||
|
|
||||||
|
<text x="30" y="50" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#f1f5f9">Your Family's Voice, Protected</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
BIN
assets/ads/meta_a_1x1_1080x1080.png
Normal file
|
After Width: | Height: | Size: 193 KiB |
60
assets/ads/meta_a_1x1_1080x1080.svg
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1080" viewBox="0 0 1080 1080">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgGradL" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#0a1528"/>
|
||||||
|
<stop offset="100%" stop-color="#0f1d35"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="bgGradR" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#1a0a0a"/>
|
||||||
|
<stop offset="100%" stop-color="#2d0f0f"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6"/>
|
||||||
|
<stop offset="100%" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="distortGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#ef444466"/>
|
||||||
|
<stop offset="100%" stop-color="#ef444422"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="glitch">
|
||||||
|
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="3" result="noise"/>
|
||||||
|
<feDisplacementMap in="SourceGraphic" in2="noise" scale="8" xChannelSelector="R" yChannelSelector="G"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Left panel: normal family -->
|
||||||
|
<rect x="0" y="0" width="540" height="1080" fill="url(#bgGradL)"/>
|
||||||
|
<circle cx="270" cy="280" r="60" fill="#3b82f630" stroke="#3b82f6" stroke-width="2"/>
|
||||||
|
<circle cx="210" cy="220" r="40" fill="#3b82f620" stroke="#3b82f6" stroke-width="1.5"/>
|
||||||
|
<circle cx="340" cy="230" r="35" fill="#3b82f620" stroke="#3b82f6" stroke-width="1.5"/>
|
||||||
|
<circle cx="240" cy="360" r="45" fill="#3b82f620" stroke="#3b82f6" stroke-width="1.5"/>
|
||||||
|
<rect x="200" y="420" width="140" height="180" rx="10" fill="#3b82f615" stroke="#3b82f6" stroke-width="1.5" opacity="0.6"/>
|
||||||
|
<text x="270" y="680" font-family="system-ui, sans-serif" font-size="22" font-weight="600" fill="#f1f5f9" text-anchor="middle">Your Family</text>
|
||||||
|
<text x="270" y="710" font-family="system-ui, sans-serif" font-size="15" fill="#94a3b8" text-anchor="middle">Real & Unfiltered</text>
|
||||||
|
|
||||||
|
<!-- Center divider with phone icon -->
|
||||||
|
<rect x="538" y="0" width="4" height="1080" fill="#1e293b"/>
|
||||||
|
<g transform="translate(540, 480)">
|
||||||
|
<rect x="-25" y="-50" width="50" height="100" rx="10" fill="#3b82f6" opacity="0.3"/>
|
||||||
|
<path d="M-10,-10 Q-20,0 -10,10" fill="none" stroke="#ef4444" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<path d="M0,-20 Q-25,0 0,20" fill="none" stroke="#ef4444" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<path d="M10,-30 Q-30,0 10,30" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" opacity="0.7"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Right panel: distorted/AI -->
|
||||||
|
<rect x="540" y="0" width="540" height="1080" fill="url(#bgGradR)"/>
|
||||||
|
<g filter="url(#glitch)">
|
||||||
|
<circle cx="810" cy="280" r="60" fill="#ef444430" stroke="#ef4444" stroke-width="2"/>
|
||||||
|
<circle cx="750" cy="220" r="40" fill="#ef444420" stroke="#ef4444" stroke-width="1.5"/>
|
||||||
|
<circle cx="880" cy="230" r="35" fill="#ef444420" stroke="#ef4444" stroke-width="1.5"/>
|
||||||
|
<circle cx="780" cy="360" r="45" fill="#ef444420" stroke="#ef4444" stroke-width="1.5"/>
|
||||||
|
<rect x="740" y="420" width="140" height="180" rx="10" fill="#ef444415" stroke="#ef4444" stroke-width="1.5" opacity="0.6"/>
|
||||||
|
</g>
|
||||||
|
<text x="810" y="680" font-family="system-ui, sans-serif" font-size="22" font-weight="600" fill="#ef4444" text-anchor="middle">AI Clone</text>
|
||||||
|
<text x="810" y="710" font-family="system-ui, sans-serif" font-size="15" fill="#64748b" text-anchor="middle">Synthetic & Dangerous</text>
|
||||||
|
|
||||||
|
<!-- Bottom brand bar -->
|
||||||
|
<rect x="0" y="990" width="1080" height="90" fill="#1a2332"/>
|
||||||
|
<text x="540" y="1025" font-family="system-ui, sans-serif" font-size="22" font-weight="600" fill="#f1f5f9" text-anchor="middle">Your Family's Voice, Protected</text>
|
||||||
|
<text x="540" y="1052" font-family="system-ui, sans-serif" font-size="15" fill="#94a3b8" text-anchor="middle">ShieldAI detects AI voice cloning with 99.7% accuracy</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.7 KiB |
BIN
assets/ads/meta_b_1x1_1080x1080.png
Normal file
|
After Width: | Height: | Size: 243 KiB |
63
assets/ads/meta_b_1x1_1080x1080.svg
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1080" viewBox="0 0 1080 1080">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgTerm" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#050a05"/>
|
||||||
|
<stop offset="100%" stop-color="#0a1a0a"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#22c55e"/>
|
||||||
|
<stop offset="100%" stop-color="#16a34a"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1080" height="1080" fill="url(#bgTerm)"/>
|
||||||
|
|
||||||
|
<!-- Matrix-like grid lines -->
|
||||||
|
<g stroke="#22c55e10" stroke-width="0.5">
|
||||||
|
<line x1="0" y1="100" x2="1080" y2="100"/>
|
||||||
|
<line x1="0" y1="200" x2="1080" y2="200"/>
|
||||||
|
<line x1="0" y1="300" x2="1080" y2="300"/>
|
||||||
|
<line x1="0" y1="400" x2="1080" y2="400"/>
|
||||||
|
<line x1="0" y1="500" x2="1080" y2="500"/>
|
||||||
|
<line x1="0" y1="600" x2="1080" y2="600"/>
|
||||||
|
<line x1="0" y1="700" x2="1080" y2="700"/>
|
||||||
|
<line x1="0" y1="800" x2="1080" y2="800"/>
|
||||||
|
<line x1="0" y1="900" x2="1080" y2="900"/>
|
||||||
|
<line x1="0" y1="1000" x2="1080" y2="1000"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Terminal window frame -->
|
||||||
|
<rect x="100" y="200" width="880" height="500" rx="12" fill="#0d1f0d" stroke="#22c55e30" stroke-width="1.5"/>
|
||||||
|
<rect x="100" y="200" width="880" height="40" rx="12" fill="#143014"/>
|
||||||
|
<rect x="100" y="228" width="880" height="12" fill="#143014"/>
|
||||||
|
<circle cx="130" cy="220" r="6" fill="#ef4444"/>
|
||||||
|
<circle cx="155" cy="220" r="6" fill="#f59e0b"/>
|
||||||
|
<circle cx="180" cy="220" r="6" fill="#22c55e"/>
|
||||||
|
<text x="200" y="225" font-family="monospace" font-size="14" fill="#64748b">darkwatch@shieldai:~$</text>
|
||||||
|
|
||||||
|
<!-- Terminal content -->
|
||||||
|
<text x="130" y="280" font-family="monospace" font-size="16" fill="#f59e0b">> Scanning 150+ dark web marketplaces...</text>
|
||||||
|
<text x="130" y="320" font-family="monospace" font-size="16" fill="#f59e0b">> Analyzing breach databases...</text>
|
||||||
|
|
||||||
|
<text x="130" y="380" font-family="monospace" font-size="18" font-weight="bold" fill="#ef4444">! ALERT: MATCHES FOUND</text>
|
||||||
|
|
||||||
|
<rect x="130" y="410" width="320" height="28" fill="#ef444415"/>
|
||||||
|
<text x="140" y="430" font-family="monospace" font-size="15" fill="#ef4444">email:***@gmail.com — 3 breaches</text>
|
||||||
|
|
||||||
|
<rect x="130" y="445" width="320" height="28" fill="#ef444415"/>
|
||||||
|
<text x="140" y="465" font-family="monospace" font-size="15" fill="#ef4444">phone:+1 (555) ***-8842 — 2 breaches</text>
|
||||||
|
|
||||||
|
<rect x="130" y="480" width="320" height="28" fill="#ef444415"/>
|
||||||
|
<text x="140" y="500" font-family="monospace" font-size="15" fill="#ef4444">ssn:***-**-6781 — 1 breach</text>
|
||||||
|
|
||||||
|
<text x="130" y="550" font-family="monospace" font-size="16" fill="#22c55e">> Total exposures found: 5,284</text>
|
||||||
|
<text x="130" y="580" font-family="monospace" font-size="16" fill="#06b6d4">> Run scan on your data? [Y/n] _</text>
|
||||||
|
|
||||||
|
<!-- Bottom CTA -->
|
||||||
|
<rect x="340" y="750" width="400" height="56" rx="28" fill="#22c55e"/>
|
||||||
|
<text x="540" y="785" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#050a05" text-anchor="middle">Scan Your Email Free</text>
|
||||||
|
|
||||||
|
<text x="540" y="860" font-family="system-ui, sans-serif" font-size="16" fill="#64748b" text-anchor="middle">ShieldAI DarkWatch — 24/7 Dark Web Monitoring</text>
|
||||||
|
|
||||||
|
<text x="540" y="920" font-family="system-ui, sans-serif" font-size="28" font-weight="700" fill="#f1f5f9" text-anchor="middle">5K+ Exposures Found.</text>
|
||||||
|
<text x="540" y="960" font-family="system-ui, sans-serif" font-size="28" font-weight="700" fill="#22c55e" text-anchor="middle">What About Yours?</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
BIN
assets/ads/meta_b_45_1080x1350.png
Normal file
|
After Width: | Height: | Size: 297 KiB |
52
assets/ads/meta_b_45_1080x1350.svg
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1350" viewBox="0 0 1080 1350">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgB45" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#050a05"/>
|
||||||
|
<stop offset="100%" stop-color="#0a1a0a"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#22c55e"/>
|
||||||
|
<stop offset="100%" stop-color="#16a34a"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1080" height="1350" fill="url(#bgB45)"/>
|
||||||
|
|
||||||
|
<!-- Terminal -->
|
||||||
|
<rect x="80" y="250" width="920" height="520" rx="12" fill="#0d1f0d" stroke="#22c55e30" stroke-width="1.5"/>
|
||||||
|
<rect x="80" y="250" width="920" height="40" rx="12" fill="#143014"/>
|
||||||
|
<rect x="80" y="278" width="920" height="12" fill="#143014"/>
|
||||||
|
<circle cx="110" cy="270" r="6" fill="#ef4444"/>
|
||||||
|
<circle cx="135" cy="270" r="6" fill="#f59e0b"/>
|
||||||
|
<circle cx="160" cy="270" r="6" fill="#22c55e"/>
|
||||||
|
<text x="180" y="275" font-family="monospace" font-size="14" fill="#64748b">darkwatch@shieldai:~$</text>
|
||||||
|
|
||||||
|
<text x="110" y="330" font-family="monospace" font-size="16" fill="#f59e0b">> Scanning 150+ dark web marketplaces...</text>
|
||||||
|
<text x="110" y="360" font-family="monospace" font-size="16" fill="#f59e0b">> Cross-referencing databases...</text>
|
||||||
|
|
||||||
|
<text x="110" y="415" font-family="monospace" font-size="18" font-weight="bold" fill="#ef4444">! ALERT: DATA EXPOSED</text>
|
||||||
|
|
||||||
|
<rect x="110" y="445" width="350" height="28" fill="#ef444415"/>
|
||||||
|
<text x="120" y="465" font-family="monospace" font-size="14" fill="#ef4444">email:***@gmail.com — 3 breaches</text>
|
||||||
|
|
||||||
|
<rect x="110" y="480" width="350" height="28" fill="#ef444415"/>
|
||||||
|
<text x="120" y="500" font-family="monospace" font-size="14" fill="#ef4444">phone:+1 (555) ***-8842 — 2 breaches</text>
|
||||||
|
|
||||||
|
<rect x="110" y="515" width="350" height="28" fill="#ef444415"/>
|
||||||
|
<text x="120" y="535" font-family="monospace" font-size="14" fill="#ef4444">ssn:***-**-6781 — 1 breach</text>
|
||||||
|
|
||||||
|
<rect x="110" y="550" width="350" height="28" fill="#ef444415"/>
|
||||||
|
<text x="120" y="570" font-family="monospace" font-size="14" fill="#ef4444">Address:*** Oak St — 1 breach</text>
|
||||||
|
|
||||||
|
<text x="110" y="625" font-family="monospace" font-size="16" fill="#22c55e">> Total exposures monitored: 5,284</text>
|
||||||
|
<text x="110" y="660" font-family="monospace" font-size="16" fill="#06b6d4">> Run scan on your data? [Y/n] _</text>
|
||||||
|
|
||||||
|
<text x="540" y="840" font-family="system-ui, sans-serif" font-size="30" font-weight="700" fill="#f1f5f9" text-anchor="middle">Your Data May Already Be</text>
|
||||||
|
<text x="540" y="885" font-family="system-ui, sans-serif" font-size="30" font-weight="700" fill="#ef4444" text-anchor="middle">For Sale on the Dark Web</text>
|
||||||
|
|
||||||
|
<text x="540" y="940" font-family="system-ui, sans-serif" font-size="16" fill="#94a3b8" text-anchor="middle">ShieldAI scans 150+ marketplaces 24/7 and alerts you instantly</text>
|
||||||
|
|
||||||
|
<rect x="365" y="1000" width="350" height="56" rx="28" fill="#22c55e"/>
|
||||||
|
<text x="540" y="1035" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#050a05" text-anchor="middle">Scan Your Email Free</text>
|
||||||
|
|
||||||
|
<text x="540" y="1300" font-family="system-ui, sans-serif" font-size="14" fill="#64748b" text-anchor="middle">ShieldAI — AI-Powered Identity Protection for Everyone</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
BIN
assets/ads/meta_c_1x1_1080x1080.png
Normal file
|
After Width: | Height: | Size: 323 KiB |
46
assets/ads/meta_c_1x1_1080x1080.svg
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1080" viewBox="0 0 1080 1080">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgC" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#0a0f1e"/>
|
||||||
|
<stop offset="100%" stop-color="#050812"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6"/>
|
||||||
|
<stop offset="100%" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1080" height="1080" fill="url(#bgC)"/>
|
||||||
|
<rect width="1080" height="6" fill="url(#brandBar)"/>
|
||||||
|
|
||||||
|
<text x="540" y="140" font-family="system-ui, sans-serif" font-size="42" font-weight="700" fill="#f1f5f9" text-anchor="middle">3 Ways ShieldAI Protects Your Family</text>
|
||||||
|
<text x="540" y="185" font-family="system-ui, sans-serif" font-size="18" fill="#94a3b8" text-anchor="middle">VoicePrint + DarkWatch + SpamShield</text>
|
||||||
|
<rect x="60" y="280" width="300" height="400" rx="16" fill="#1a2332" stroke="#06b6d430" stroke-width="1.5"/>
|
||||||
|
<circle cx="210" cy="340" r="40" fill="#06b6d422" stroke="#06b6d4" stroke-width="2"/>
|
||||||
|
<text x="210" y="345" font-family="system-ui, sans-serif" font-size="18" font-weight="700" fill="#06b6d4" text-anchor="middle">VoicePrint</text>
|
||||||
|
<text x="210" y="400" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="#f1f5f9" text-anchor="middle">AI Voice Clone</text>
|
||||||
|
<text x="210" y="432" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="#f1f5f9" text-anchor="middle">Detection</text>
|
||||||
|
<text x="210" y="460" font-family="system-ui, sans-serif" font-size="14" fill="#94a3b8" text-anchor="middle">Real-time detection</text>
|
||||||
|
<text x="210" y="485" font-family="system-ui, sans-serif" font-size="14" fill="#94a3b8" text-anchor="middle">of synthetic voices</text>
|
||||||
|
<text x="210" y="510" font-family="system-ui, sans-serif" font-size="14" fill="#94a3b8" text-anchor="middle">with 99.7% accuracy</text>
|
||||||
|
<rect x="390" y="280" width="300" height="400" rx="16" fill="#1a2332" stroke="#3b82f630" stroke-width="1.5"/>
|
||||||
|
<circle cx="540" cy="340" r="40" fill="#3b82f622" stroke="#3b82f6" stroke-width="2"/>
|
||||||
|
<text x="540" y="345" font-family="system-ui, sans-serif" font-size="18" font-weight="700" fill="#3b82f6" text-anchor="middle">DarkWatch</text>
|
||||||
|
<text x="540" y="400" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="#f1f5f9" text-anchor="middle">Dark Web</text>
|
||||||
|
<text x="540" y="432" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="#f1f5f9" text-anchor="middle">Monitoring</text>
|
||||||
|
<text x="540" y="460" font-family="system-ui, sans-serif" font-size="14" fill="#94a3b8" text-anchor="middle">24/7 scanning of</text>
|
||||||
|
<text x="540" y="485" font-family="system-ui, sans-serif" font-size="14" fill="#94a3b8" text-anchor="middle">150+ marketplaces</text>
|
||||||
|
<text x="540" y="510" font-family="system-ui, sans-serif" font-size="14" fill="#94a3b8" text-anchor="middle">for your data</text>
|
||||||
|
<rect x="720" y="280" width="300" height="400" rx="16" fill="#1a2332" stroke="#22c55e30" stroke-width="1.5"/>
|
||||||
|
<circle cx="870" cy="340" r="40" fill="#22c55e22" stroke="#22c55e" stroke-width="2"/>
|
||||||
|
<text x="870" y="345" font-family="system-ui, sans-serif" font-size="18" font-weight="700" fill="#22c55e" text-anchor="middle">SpamShield</text>
|
||||||
|
<text x="870" y="400" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="#f1f5f9" text-anchor="middle">Spam Call &</text>
|
||||||
|
<text x="870" y="432" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="#f1f5f9" text-anchor="middle">Text Blocking</text>
|
||||||
|
<text x="870" y="460" font-family="system-ui, sans-serif" font-size="14" fill="#94a3b8" text-anchor="middle">AI-powered filtering</text>
|
||||||
|
<text x="870" y="485" font-family="system-ui, sans-serif" font-size="14" fill="#94a3b8" text-anchor="middle">of spam calls</text>
|
||||||
|
<text x="870" y="510" font-family="system-ui, sans-serif" font-size="14" fill="#94a3b8" text-anchor="middle">and text messages</text>
|
||||||
|
<rect x="405" y="760" width="270" height="52" rx="26" fill="#3b82f6"/>
|
||||||
|
<text x="540" y="793" font-family="system-ui, sans-serif" font-size="18" font-weight="600" fill="#f1f5f9" text-anchor="middle">Join the Waitlist</text>
|
||||||
|
|
||||||
|
<text x="540" y="870" font-family="system-ui, sans-serif" font-size="15" fill="#64748b" text-anchor="middle">Three critical protections, one powerful platform</text>
|
||||||
|
<text x="540" y="900" font-family="system-ui, sans-serif" font-size="14" fill="#64748b" text-anchor="middle">Start free. Launching soon.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.4 KiB |
BIN
assets/ads/meta_d_191_1200x628.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
60
assets/ads/meta_d_191_1200x628.svg
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="628" viewBox="0 0 1200 628">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="shieldGlow" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stop-color="#3b82f630"/>
|
||||||
|
<stop offset="100%" stop-color="#3b82f600"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgD" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#0a0f1e"/>
|
||||||
|
<stop offset="60%" stop-color="#0d1a30"/>
|
||||||
|
<stop offset="100%" stop-color="#0a0f1e"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6"/>
|
||||||
|
<stop offset="100%" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shieldGradD" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6"/>
|
||||||
|
<stop offset="100%" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="628" fill="url(#bgD)"/>
|
||||||
|
<rect width="1200" height="5" fill="url(#brandBar)"/>
|
||||||
|
|
||||||
|
<!-- Digital shield overlay -->
|
||||||
|
<circle cx="600" cy="314" r="238.64000000000001" fill="url(#shieldGlow)"/>
|
||||||
|
<g transform="translate(600, 284)">
|
||||||
|
<path d="M-60,-55 L60,-55 L65,15 Q65,55 35,75 L0,90 L-35,75 Q-65,55 -65,15 Z" fill="none" stroke="url(#shieldGradD)" stroke-width="3" opacity="0.8"/>
|
||||||
|
<path d="M-25,-5 L0,25 L30,-15" fill="none" stroke="#22c55e" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Family figures (simplified) -->
|
||||||
|
|
||||||
|
<!-- Grandparent L -->
|
||||||
|
<circle cx="490" cy="319" r="22" fill="#33415580"/>
|
||||||
|
<rect x="475" y="344" width="30" height="50" rx="8" fill="#33415560"/>
|
||||||
|
|
||||||
|
<!-- Parent L -->
|
||||||
|
<circle cx="550" cy="304" r="25" fill="#47556980"/>
|
||||||
|
<rect x="532" y="332" width="36" height="65" rx="10" fill="#47556960"/>
|
||||||
|
|
||||||
|
<!-- Child -->
|
||||||
|
<circle cx="615" cy="314" r="18" fill="#64748b80"/>
|
||||||
|
<rect x="602" y="334" width="26" height="40" rx="8" fill="#64748b60"/>
|
||||||
|
|
||||||
|
<!-- Parent R -->
|
||||||
|
<circle cx="680" cy="304" r="25" fill="#47556980"/>
|
||||||
|
<rect x="662" y="332" width="36" height="65" rx="10" fill="#47556960"/>
|
||||||
|
|
||||||
|
<!-- Grandparent R -->
|
||||||
|
<circle cx="740" cy="319" r="22" fill="#33415580"/>
|
||||||
|
<rect x="725" y="344" width="30" height="50" rx="8" fill="#33415560"/>
|
||||||
|
|
||||||
|
|
||||||
|
<text x="600" y="468" font-family="system-ui, sans-serif" font-size="32" font-weight="700" fill="#f1f5f9" text-anchor="middle">Protect Your Whole Family</text>
|
||||||
|
<text x="600" y="513" font-family="system-ui, sans-serif" font-size="17" fill="#94a3b8" text-anchor="middle">AI voice clone detection + dark web monitoring + spam blocking</text>
|
||||||
|
<text x="600" y="543" font-family="system-ui, sans-serif" font-size="17" fill="#94a3b8" text-anchor="middle">for up to unlimited family members on Premium</text>
|
||||||
|
|
||||||
|
<rect x="485" y="568" width="230" height="46" rx="23" fill="#3b82f6"/>
|
||||||
|
<text x="600" y="595" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="#f1f5f9" text-anchor="middle">Protect My Family</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
BIN
assets/ads/meta_d_1x1_1080x1080.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
60
assets/ads/meta_d_1x1_1080x1080.svg
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1080" viewBox="0 0 1080 1080">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="shieldGlow" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stop-color="#3b82f630"/>
|
||||||
|
<stop offset="100%" stop-color="#3b82f600"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgD" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#0a0f1e"/>
|
||||||
|
<stop offset="60%" stop-color="#0d1a30"/>
|
||||||
|
<stop offset="100%" stop-color="#0a0f1e"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6"/>
|
||||||
|
<stop offset="100%" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shieldGradD" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6"/>
|
||||||
|
<stop offset="100%" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1080" height="1080" fill="url(#bgD)"/>
|
||||||
|
<rect width="1080" height="5" fill="url(#brandBar)"/>
|
||||||
|
|
||||||
|
<!-- Digital shield overlay -->
|
||||||
|
<circle cx="540" cy="540" r="410.4" fill="url(#shieldGlow)"/>
|
||||||
|
<g transform="translate(540, 510)">
|
||||||
|
<path d="M-60,-55 L60,-55 L65,15 Q65,55 35,75 L0,90 L-35,75 Q-65,55 -65,15 Z" fill="none" stroke="url(#shieldGradD)" stroke-width="3" opacity="0.8"/>
|
||||||
|
<path d="M-25,-5 L0,25 L30,-15" fill="none" stroke="#22c55e" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Family figures (simplified) -->
|
||||||
|
|
||||||
|
<!-- Grandparent L -->
|
||||||
|
<circle cx="430" cy="545" r="22" fill="#33415580"/>
|
||||||
|
<rect x="415" y="570" width="30" height="50" rx="8" fill="#33415560"/>
|
||||||
|
|
||||||
|
<!-- Parent L -->
|
||||||
|
<circle cx="490" cy="530" r="25" fill="#47556980"/>
|
||||||
|
<rect x="472" y="558" width="36" height="65" rx="10" fill="#47556960"/>
|
||||||
|
|
||||||
|
<!-- Child -->
|
||||||
|
<circle cx="555" cy="540" r="18" fill="#64748b80"/>
|
||||||
|
<rect x="542" y="560" width="26" height="40" rx="8" fill="#64748b60"/>
|
||||||
|
|
||||||
|
<!-- Parent R -->
|
||||||
|
<circle cx="620" cy="530" r="25" fill="#47556980"/>
|
||||||
|
<rect x="602" y="558" width="36" height="65" rx="10" fill="#47556960"/>
|
||||||
|
|
||||||
|
<!-- Grandparent R -->
|
||||||
|
<circle cx="680" cy="545" r="22" fill="#33415580"/>
|
||||||
|
<rect x="665" y="570" width="30" height="50" rx="8" fill="#33415560"/>
|
||||||
|
|
||||||
|
|
||||||
|
<text x="540" y="920" font-family="system-ui, sans-serif" font-size="32" font-weight="700" fill="#f1f5f9" text-anchor="middle">Protect Your Whole Family</text>
|
||||||
|
<text x="540" y="965" font-family="system-ui, sans-serif" font-size="17" fill="#94a3b8" text-anchor="middle">AI voice clone detection + dark web monitoring + spam blocking</text>
|
||||||
|
<text x="540" y="995" font-family="system-ui, sans-serif" font-size="17" fill="#94a3b8" text-anchor="middle">for up to unlimited family members on Premium</text>
|
||||||
|
|
||||||
|
<rect x="425" y="1020" width="230" height="46" rx="23" fill="#3b82f6"/>
|
||||||
|
<text x="540" y="1047" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="#f1f5f9" text-anchor="middle">Protect My Family</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
BIN
assets/ads/meta_d_45_1080x1350.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
60
assets/ads/meta_d_45_1080x1350.svg
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1350" viewBox="0 0 1080 1350">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="shieldGlow" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stop-color="#3b82f630"/>
|
||||||
|
<stop offset="100%" stop-color="#3b82f600"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bgD" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#0a0f1e"/>
|
||||||
|
<stop offset="60%" stop-color="#0d1a30"/>
|
||||||
|
<stop offset="100%" stop-color="#0a0f1e"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6"/>
|
||||||
|
<stop offset="100%" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shieldGradD" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6"/>
|
||||||
|
<stop offset="100%" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1080" height="1350" fill="url(#bgD)"/>
|
||||||
|
<rect width="1080" height="5" fill="url(#brandBar)"/>
|
||||||
|
|
||||||
|
<!-- Digital shield overlay -->
|
||||||
|
<circle cx="540" cy="675" r="410.4" fill="url(#shieldGlow)"/>
|
||||||
|
<g transform="translate(540, 645)">
|
||||||
|
<path d="M-60,-55 L60,-55 L65,15 Q65,55 35,75 L0,90 L-35,75 Q-65,55 -65,15 Z" fill="none" stroke="url(#shieldGradD)" stroke-width="3" opacity="0.8"/>
|
||||||
|
<path d="M-25,-5 L0,25 L30,-15" fill="none" stroke="#22c55e" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Family figures (simplified) -->
|
||||||
|
|
||||||
|
<!-- Grandparent L -->
|
||||||
|
<circle cx="430" cy="680" r="22" fill="#33415580"/>
|
||||||
|
<rect x="415" y="705" width="30" height="50" rx="8" fill="#33415560"/>
|
||||||
|
|
||||||
|
<!-- Parent L -->
|
||||||
|
<circle cx="490" cy="665" r="25" fill="#47556980"/>
|
||||||
|
<rect x="472" y="693" width="36" height="65" rx="10" fill="#47556960"/>
|
||||||
|
|
||||||
|
<!-- Child -->
|
||||||
|
<circle cx="555" cy="675" r="18" fill="#64748b80"/>
|
||||||
|
<rect x="542" y="695" width="26" height="40" rx="8" fill="#64748b60"/>
|
||||||
|
|
||||||
|
<!-- Parent R -->
|
||||||
|
<circle cx="620" cy="665" r="25" fill="#47556980"/>
|
||||||
|
<rect x="602" y="693" width="36" height="65" rx="10" fill="#47556960"/>
|
||||||
|
|
||||||
|
<!-- Grandparent R -->
|
||||||
|
<circle cx="680" cy="680" r="22" fill="#33415580"/>
|
||||||
|
<rect x="665" y="705" width="30" height="50" rx="8" fill="#33415560"/>
|
||||||
|
|
||||||
|
|
||||||
|
<text x="540" y="1190" font-family="system-ui, sans-serif" font-size="32" font-weight="700" fill="#f1f5f9" text-anchor="middle">Protect Your Whole Family</text>
|
||||||
|
<text x="540" y="1235" font-family="system-ui, sans-serif" font-size="17" fill="#94a3b8" text-anchor="middle">AI voice clone detection + dark web monitoring + spam blocking</text>
|
||||||
|
<text x="540" y="1265" font-family="system-ui, sans-serif" font-size="17" fill="#94a3b8" text-anchor="middle">for up to unlimited family members on Premium</text>
|
||||||
|
|
||||||
|
<rect x="425" y="1290" width="230" height="46" rx="23" fill="#3b82f6"/>
|
||||||
|
<text x="540" y="1317" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="#f1f5f9" text-anchor="middle">Protect My Family</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -1,5 +1,17 @@
|
|||||||
version: '3.9'
|
version: '3.9'
|
||||||
|
|
||||||
|
x-monitoring: &monitoring
|
||||||
|
DD_ENV: ${DD_ENV:-production}
|
||||||
|
DD_SERVICE: ${DD_SERVICE:-shieldai}
|
||||||
|
DD_VERSION: ${DOCKER_TAG:-latest}
|
||||||
|
DD_TRACE_ENABLED: ${DD_TRACE_ENABLED:-true}
|
||||||
|
DD_AGENT_HOST: datadog-agent
|
||||||
|
DD_AGENT_PORT: "8126"
|
||||||
|
DD_LOGS_INJECTION: "true"
|
||||||
|
SENTRY_DSN: ${SENTRY_DSN:-}
|
||||||
|
SENTRY_ENVIRONMENT: ${DD_ENV:-production}
|
||||||
|
SENTRY_RELEASE: ${DOCKER_TAG:-latest}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
api:
|
api:
|
||||||
image: ghcr.io/${GITHUB_REPOSITORY_OWNER}/shieldai-api:${DOCKER_TAG:-latest}
|
image: ghcr.io/${GITHUB_REPOSITORY_OWNER}/shieldai-api:${DOCKER_TAG:-latest}
|
||||||
@@ -7,12 +19,13 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${PORT:-3000}:3000"
|
- "${PORT:-3000}:3000"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://shieldai:${POSTGRES_PASSWORD}@postgres:5432/shieldai
|
DATABASE_URL: "postgresql://shieldai:${POSTGRES_PASSWORD}@postgres:5432/shieldai"
|
||||||
- REDIS_URL=redis://redis:6379
|
REDIS_URL: "redis://redis:6379"
|
||||||
- PORT=3000
|
PORT: "3000"
|
||||||
- LOG_LEVEL=info
|
LOG_LEVEL: info
|
||||||
- HIBP_API_KEY=${HIBP_API_KEY}
|
HIBP_API_KEY: ${HIBP_API_KEY}
|
||||||
- RESEND_API_KEY=${RESEND_API_KEY}
|
RESEND_API_KEY: ${RESEND_API_KEY}
|
||||||
|
<<: *monitoring
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -25,9 +38,11 @@ services:
|
|||||||
image: ghcr.io/${GITHUB_REPOSITORY_OWNER}/shieldai-darkwatch:${DOCKER_TAG:-latest}
|
image: ghcr.io/${GITHUB_REPOSITORY_OWNER}/shieldai-darkwatch:${DOCKER_TAG:-latest}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://shieldai:${POSTGRES_PASSWORD}@postgres:5432/shieldai
|
DATABASE_URL: "postgresql://shieldai:${POSTGRES_PASSWORD}@postgres:5432/shieldai"
|
||||||
- REDIS_URL=redis://redis:6379
|
REDIS_URL: "redis://redis:6379"
|
||||||
- HIBP_API_KEY=${HIBP_API_KEY}
|
HIBP_API_KEY: ${HIBP_API_KEY}
|
||||||
|
DD_SERVICE: "shieldai-darkwatch"
|
||||||
|
<<: *monitoring
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -40,8 +55,10 @@ services:
|
|||||||
image: ghcr.io/${GITHUB_REPOSITORY_OWNER}/shieldai-spamshield:${DOCKER_TAG:-latest}
|
image: ghcr.io/${GITHUB_REPOSITORY_OWNER}/shieldai-spamshield:${DOCKER_TAG:-latest}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://shieldai:${POSTGRES_PASSWORD}@postgres:5432/shieldai
|
DATABASE_URL: "postgresql://shieldai:${POSTGRES_PASSWORD}@postgres:5432/shieldai"
|
||||||
- REDIS_URL=redis://redis:6379
|
REDIS_URL: "redis://redis:6379"
|
||||||
|
DD_SERVICE: "shieldai-spamshield"
|
||||||
|
<<: *monitoring
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -54,8 +71,10 @@ services:
|
|||||||
image: ghcr.io/${GITHUB_REPOSITORY_OWNER}/shieldai-voiceprint:${DOCKER_TAG:-latest}
|
image: ghcr.io/${GITHUB_REPOSITORY_OWNER}/shieldai-voiceprint:${DOCKER_TAG:-latest}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://shieldai:${POSTGRES_PASSWORD}@postgres:5432/shieldai
|
DATABASE_URL: "postgresql://shieldai:${POSTGRES_PASSWORD}@postgres:5432/shieldai"
|
||||||
- REDIS_URL=redis://redis:6379
|
REDIS_URL: "redis://redis:6379"
|
||||||
|
DD_SERVICE: "shieldai-voiceprint"
|
||||||
|
<<: *monitoring
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -64,6 +83,29 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- shieldai
|
- shieldai
|
||||||
|
|
||||||
|
datadog-agent:
|
||||||
|
image: datadog/agent:7
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DD_API_KEY: ${DD_API_KEY}
|
||||||
|
DD_SITE: ${DD_SITE:-datadoghq.com}
|
||||||
|
DD_ENV: ${DD_ENV:-production}
|
||||||
|
DD_DOGSTATSD_NON_LOCAL_TRAFFIC: "true"
|
||||||
|
DD_APM_ENABLED: "true"
|
||||||
|
DD_APM_NON_LOCAL_TRAFFIC: "true"
|
||||||
|
DD_LOGS_ENABLED: "true"
|
||||||
|
DD_LOGS_CONFIG_CONTAINER_COLLECT_ALL: "true"
|
||||||
|
DD_HEALTH_PORT_ENABLE: "true"
|
||||||
|
ports:
|
||||||
|
- "8125:8125/udp"
|
||||||
|
- "8126:8126"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- /proc/:/host/proc/:ro
|
||||||
|
- /sys/fs/cgroup:/host/sys/fs/cgroup:ro
|
||||||
|
networks:
|
||||||
|
- shieldai
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
9
infra/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.terraform/
|
||||||
|
*.tfstate
|
||||||
|
*.tfstate.backup
|
||||||
|
*.tfvars
|
||||||
|
.terraform.lock.hcl
|
||||||
|
override.tf
|
||||||
|
override.tf.json
|
||||||
|
*_override.tf
|
||||||
|
*_override.tf.json
|
||||||
113
infra/README.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/infra/
|
||||||
|
├── main.tf # Root module: VPC, ECS, RDS, ElastiCache, S3, Secrets, CloudWatch
|
||||||
|
├── variables.tf # Input variables with validation
|
||||||
|
├── outputs.tf # Output values (endpoints, ARNs, URLs)
|
||||||
|
├── modules/
|
||||||
|
│ ├── vpc/main.tf # VPC, subnets, IGW, NAT GW, security groups
|
||||||
|
│ ├── ecs/main.tf # ECS cluster, task definitions, services, ALB, auto-scaling
|
||||||
|
│ ├── rds/main.tf # RDS PostgreSQL with automated backups
|
||||||
|
│ ├── elasticache/main.tf # ElastiCache Redis with replication
|
||||||
|
│ ├── s3/main.tf # S3 buckets: state, artifacts, logs
|
||||||
|
│ ├── secrets/main.tf # AWS Secrets Manager
|
||||||
|
│ └── cloudwatch/main.tf # Dashboards, alarms, notifications
|
||||||
|
├── environments/
|
||||||
|
│ ├── staging/main.tf # Staging environment config
|
||||||
|
│ └── production/main.tf # Production environment config
|
||||||
|
└── scripts/
|
||||||
|
├── rollback.sh # ECS service rollback (AWS)
|
||||||
|
├── rollback-compose.sh # Docker Compose rollback (local/staging)
|
||||||
|
└── rollback-migration.sh # Database migration rollback
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Terraform >= 1.5.0
|
||||||
|
- AWS CLI configured with appropriate credentials
|
||||||
|
- AWS account with ECS, RDS, ElastiCache permissions
|
||||||
|
|
||||||
|
### Initialize
|
||||||
|
```bash
|
||||||
|
cd infra/environments/staging
|
||||||
|
terraform init
|
||||||
|
terraform plan -var-file=terraform.tfvars.example
|
||||||
|
terraform apply -var-file=terraform.tfvars.example
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deploy via CI/CD
|
||||||
|
- Push to `main` → deploys to staging
|
||||||
|
- Create a release → deploys to production
|
||||||
|
- Health check failure → automatic rollback
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Networking
|
||||||
|
- VPC with public/private subnets across multiple AZs
|
||||||
|
- NAT Gateway for outbound traffic from private subnets
|
||||||
|
- Security groups: ECS → RDS (5432), ECS → ElastiCache (6379)
|
||||||
|
|
||||||
|
### Compute
|
||||||
|
- ECS Fargate for serverless container orchestration
|
||||||
|
- Application Load Balancer with health checks
|
||||||
|
- Auto-scaling: CPU-based scaling (70% target)
|
||||||
|
- Production: 3 replicas per service, min 2, max 10
|
||||||
|
|
||||||
|
### Data
|
||||||
|
- RDS PostgreSQL 16.2 with Multi-AZ (production)
|
||||||
|
- Automated daily backups, 7-14 day retention
|
||||||
|
- ElastiCache Redis 7.0 with replication
|
||||||
|
- S3 with versioning and lifecycle policies
|
||||||
|
|
||||||
|
### Secrets
|
||||||
|
- AWS Secrets Manager for all credentials
|
||||||
|
- ECS task execution role with SecretsManagerReadOnly
|
||||||
|
- DB credentials auto-rotated via RDS integration
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
- CloudWatch dashboards: CPU, memory, ALB metrics
|
||||||
|
- Alarms: CPU >80%, memory >85%, 5xx >10/min, RDS storage <500MB
|
||||||
|
- Container Insights enabled for ECS
|
||||||
|
- Logs: 30-day retention (production), 7-day (staging)
|
||||||
|
|
||||||
|
### Backup Strategy
|
||||||
|
- RDS: automated snapshots every 24h, 7-14 day retention
|
||||||
|
- RDS: Multi-AZ for automatic failover (production)
|
||||||
|
- ElastiCache: daily snapshots, 1-7 day retention
|
||||||
|
- S3: versioning enabled, non-current versions expire after 30 days
|
||||||
|
- Terraform state: S3 with versioning + DynamoDB locking
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
See **[ROLLBACK.md](./ROLLBACK.md)** for the complete rollback runbook, including:
|
||||||
|
|
||||||
|
- ECS service rollback (automated + manual)
|
||||||
|
- Docker Compose rollback (local / staging)
|
||||||
|
- Database migration rollback (Drizzle)
|
||||||
|
- Blue-green deployment rollback
|
||||||
|
- RDS point-in-time recovery
|
||||||
|
- Automated rollback triggers and health checks
|
||||||
|
- Emergency rollback runbook
|
||||||
|
- Testing checklist
|
||||||
|
|
||||||
|
### Quick Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ECS service rollback (AWS)
|
||||||
|
./infra/scripts/rollback.sh <environment> <service|all> [--verify]
|
||||||
|
|
||||||
|
# Docker Compose rollback (local/staging)
|
||||||
|
./infra/scripts/rollback-compose.sh <previous_tag>
|
||||||
|
|
||||||
|
# Database migration rollback
|
||||||
|
./infra/scripts/rollback-migration.sh <environment> [--migration <name>]
|
||||||
|
```
|
||||||
|
|
||||||
|
## GitHub Secrets Required
|
||||||
|
| Secret | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| AWS_ACCESS_KEY_ID | IAM user with ECS, RDS, ElastiCache permissions |
|
||||||
|
| AWS_SECRET_ACCESS_KEY | IAM secret key |
|
||||||
|
| HIBP_API_KEY | Have I Been Pwned API key |
|
||||||
|
| RESEND_API_KEY | Resend email API key |
|
||||||
|
| SENTRY_DSN | Sentry error tracking DSN |
|
||||||
|
| DATADOG_API_KEY | Datadog monitoring API key |
|
||||||
|
| GITHUB_TOKEN | Auto-provided, needs write:packages scope |
|
||||||
611
infra/ROLLBACK.md
Normal file
@@ -0,0 +1,611 @@
|
|||||||
|
# ShieldAI Rollback Runbook
|
||||||
|
|
||||||
|
> **Last updated:** 2026-05-12
|
||||||
|
> **Owner:** Senior Engineer
|
||||||
|
> **Parent:** [FRE-4574](/FRE/issues/FRE-4574) ShieldAI Production Infrastructure & CI/CD Pipeline
|
||||||
|
> **Reviewed by:** Code Reviewer (FRE-4808) on 2026-05-12
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Overview](#1-overview)
|
||||||
|
2. [Rollback Strategies](#2-rollback-strategies)
|
||||||
|
3. [ECS Service Rollback (AWS)](#3-ecs-service-rollback-aws)
|
||||||
|
4. [Docker Compose Rollback (Local / Staging)](#4-docker-compose-rollback-local--staging)
|
||||||
|
5. [Database Migration Rollback](#5-database-migration-rollback)
|
||||||
|
6. [Automated Rollback Triggers](#6-automated-rollback-triggers)
|
||||||
|
7. [Blue-Green Deployment Rollback](#7-blue-green-deployment-rollback)
|
||||||
|
8. [Rollback Decision Tree](#8-rollback-decision-tree)
|
||||||
|
9. [Post-Rollback Verification](#9-post-rollback-verification)
|
||||||
|
10. [Testing Checklist](#10-testing-checklist)
|
||||||
|
11. [Runbook: Emergency Rollback](#11-runbook-emergency-rollback)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
ShieldAI runs four services (api, darkwatch, spamshield, voiceprint) on AWS ECS Fargate behind an Application Load Balancer. Each service has independent deployment, health checks, and rollback capability.
|
||||||
|
|
||||||
|
**Rollback types:**
|
||||||
|
|
||||||
|
| Type | Trigger | Scope | Automation |
|
||||||
|
|------|---------|-------|------------|
|
||||||
|
| **ECS Service Rollback** | Health check failure, manual | Single or all services | ✅ CI/CD + manual script |
|
||||||
|
| **Docker Compose Rollback** | Manual (local/staging) | All services | ✅ Scripted |
|
||||||
|
| **Database Migration Rollback** | Manual | Schema changes | ⚠️ Semi-manual |
|
||||||
|
| **Blue-Green Rollback** | Manual or automated | Full environment | ✅ CI/CD |
|
||||||
|
| **RDS Point-in-Time Restore** | Manual (disaster) | Full database | ⚠️ Semi-manual |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Rollback Strategies
|
||||||
|
|
||||||
|
### 2.1 ECS Service-Level Rollback
|
||||||
|
|
||||||
|
Each ECS service maintains a history of task definitions. Rolling back reverts to the **previous successfully deployed task definition**.
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- AWS CLI configured with credentials for the target environment
|
||||||
|
- IAM permissions: `ecs:UpdateService`, `ecs:DescribeServices`, `ecs:WaitServicesStable`
|
||||||
|
|
||||||
|
### 2.2 Blue-Green Rollback
|
||||||
|
|
||||||
|
The CI/CD pipeline deploys new images to existing ECS services. If health checks fail after deployment, the `rollback` job in the deploy workflow automatically reverts all four services to their previous task definition revision.
|
||||||
|
|
||||||
|
**Pipeline flow:**
|
||||||
|
```
|
||||||
|
build-and-push → deploy-ecs → health-check → [PASS: done | FAIL: rollback]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Database Migration Rollback
|
||||||
|
|
||||||
|
ShieldAI uses Drizzle ORM for database migrations. Each migration is versioned and stored in `src/db/migrations/`. Rollback requires running the previous migration set.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ECS Service Rollback (AWS)
|
||||||
|
|
||||||
|
### 3.1 Automated (CI/CD Pipeline)
|
||||||
|
|
||||||
|
The deploy workflow (`.github/workflows/deploy.yml`) includes a `rollback` job that triggers on health check failure:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
rollback:
|
||||||
|
if: failure() && needs.health-check.result == 'failure'
|
||||||
|
# Rolls back all 4 services to previous task definition
|
||||||
|
```
|
||||||
|
|
||||||
|
**When it runs:**
|
||||||
|
- Post-deploy health check fails (HTTP 200 not received from `/health`)
|
||||||
|
- Runs after `deploy-ecs` and `health-check` jobs
|
||||||
|
- Rolls back all four services: api, darkwatch, spamshield, voiceprint
|
||||||
|
|
||||||
|
**How to verify:**
|
||||||
|
1. Navigate to the GitHub Actions run for the failed deployment
|
||||||
|
2. Check the `Rollback on Failure` job logs
|
||||||
|
3. Confirm each service shows "Rolled back" status
|
||||||
|
|
||||||
|
### 3.2 Manual Rollback Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Single service
|
||||||
|
./infra/scripts/rollback.sh production api
|
||||||
|
|
||||||
|
# All services
|
||||||
|
./infra/scripts/rollback.sh production all
|
||||||
|
|
||||||
|
# Staging environment
|
||||||
|
./infra/scripts/rollback.sh staging all
|
||||||
|
```
|
||||||
|
|
||||||
|
**Script behavior:**
|
||||||
|
1. Iterates over target services (or all if `all` specified)
|
||||||
|
2. Calls `aws ecs update-service --rollback` for each service
|
||||||
|
3. Waits for service to stabilize via `aws ecs wait services-stable`
|
||||||
|
4. Reports success/failure per service
|
||||||
|
5. Exits with non-zero code if any service fails to stabilize
|
||||||
|
|
||||||
|
**Expected output:**
|
||||||
|
```
|
||||||
|
Rolling back services in cluster: shieldai-production
|
||||||
|
Rolling back api...
|
||||||
|
Waiting for api to stabilize...
|
||||||
|
api rolled back successfully
|
||||||
|
Rolling back darkwatch...
|
||||||
|
Waiting for darkwatch to stabilize...
|
||||||
|
darkwatch rolled back successfully
|
||||||
|
...
|
||||||
|
Rollback complete for api darkwatch spamshield voiceprint
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Manual CLI Rollback (Fallback)
|
||||||
|
|
||||||
|
If the script is unavailable, rollback individual services:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CLUSTER="shieldai-production"
|
||||||
|
SERVICE="api"
|
||||||
|
|
||||||
|
# Rollback to previous task definition
|
||||||
|
aws ecs update-service \
|
||||||
|
--cluster "$CLUSTER" \
|
||||||
|
--service "${CLUSTER}-${SERVICE}" \
|
||||||
|
--rollback \
|
||||||
|
--no-cli-auto-prompt
|
||||||
|
|
||||||
|
# Wait for stabilization
|
||||||
|
aws ecs wait services-stable \
|
||||||
|
--cluster "$CLUSTER" \
|
||||||
|
--services "${CLUSTER}-${SERVICE}"
|
||||||
|
|
||||||
|
# Verify health
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
"https://shieldai-production-alb.us-east-1.elb.amazonaws.com/health"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Docker Compose Rollback (Local / Staging)
|
||||||
|
|
||||||
|
### 4.1 Production Compose Rollback
|
||||||
|
|
||||||
|
The `docker-compose.prod.yml` deploys all services with tagged images. To rollback:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Identify the previous working tag
|
||||||
|
# Check GitHub releases or git tags for the last known good version
|
||||||
|
PREVIOUS_TAG="v1.2.3"
|
||||||
|
|
||||||
|
# 2. Stop current services
|
||||||
|
docker compose -f docker-compose.prod.yml down
|
||||||
|
|
||||||
|
# 3. Pull previous images
|
||||||
|
docker pull ghcr.io/${GITHUB_REPOSITORY_OWNER}/shieldai-api:${PREVIOUS_TAG}
|
||||||
|
docker pull ghcr.io/${GITHUB_REPOSITORY_OWNER}/shieldai-darkwatch:${PREVIOUS_TAG}
|
||||||
|
docker pull ghcr.io/${GITHUB_REPOSITORY_OWNER}/shieldai-spamshield:${PREVIOUS_TAG}
|
||||||
|
docker pull ghcr.io/${GITHUB_REPOSITORY_OWNER}/shieldai-voiceprint:${PREVIOUS_TAG}
|
||||||
|
|
||||||
|
# 4. Override tag in compose
|
||||||
|
DOCKER_TAG=${PREVIOUS_TAG} docker compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
# 5. Verify health
|
||||||
|
for svc in api darkwatch spamshield voiceprint; do
|
||||||
|
PORT=$(case $svc in
|
||||||
|
api) echo 3000;; darkwatch) echo 3001;;
|
||||||
|
spamshield) echo 3002;; voiceprint) echo 3003;;
|
||||||
|
esac)
|
||||||
|
curl -sf "http://localhost:${PORT}/health" && echo "$svc: OK" || echo "$svc: FAIL"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Local Dev Rollback
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop and remove containers
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Rebuild from previous commit
|
||||||
|
git checkout <previous-commit>
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Database Migration Rollback
|
||||||
|
|
||||||
|
### 5.1 Drizzle Migration Rollback
|
||||||
|
|
||||||
|
ShieldAI uses Drizzle ORM with Turso dialect. Migrations are stored in `src/db/migrations/`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Get database credentials from AWS Secrets Manager
|
||||||
|
DB_SECRET=$(aws secretsmanager get-secret-value \
|
||||||
|
--secret-id "shieldai-${ENVIRONMENT}-db-password" \
|
||||||
|
--query 'SecretString' --output json)
|
||||||
|
|
||||||
|
DB_HOST=$(echo "$DB_SECRET" | jq -r '.host')
|
||||||
|
DB_PORT=$(echo "$DB_SECRET" | jq -r '.port')
|
||||||
|
DB_USER=$(echo "$DB_SECRET" | jq -r '.username')
|
||||||
|
DB_PASS=$(echo "$DB_SECRET" | jq -r '.password')
|
||||||
|
|
||||||
|
DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/shieldai"
|
||||||
|
|
||||||
|
# 2. List migrations to identify the one to revert
|
||||||
|
npx drizzle-kit introspect --config=drizzle.config.ts
|
||||||
|
|
||||||
|
# 3. Resolve the problematic migration (marks it as not applied)
|
||||||
|
npx drizzle-kit migrate:resolve --migration "<migration_name>" --status applied
|
||||||
|
|
||||||
|
# 4. Re-run previous migration state
|
||||||
|
npx drizzle-kit migrate --config=drizzle.config.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 RDS Point-in-Time Recovery (Disaster)
|
||||||
|
|
||||||
|
When the database itself needs recovery (e.g., data corruption, bad migration):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Find available recovery window (automated backups: every 24h, 7-14 day retention)
|
||||||
|
aws rds describe-db-instances \
|
||||||
|
--db-instance-identifier "shieldai-production-db" \
|
||||||
|
--query 'DBInstances[0].LatestRestorableTime'
|
||||||
|
|
||||||
|
# 2. Create restored instance (does not affect primary)
|
||||||
|
aws rds restore-db-instance-to-point-in-time \
|
||||||
|
--source-db-instance-identifier "shieldai-production-db" \
|
||||||
|
--db-instance-identifier "shieldai-production-db-restored" \
|
||||||
|
--restore-time "2026-05-09T08:00:00Z"
|
||||||
|
|
||||||
|
# 3. Verify restored instance
|
||||||
|
aws rds wait db-instance-available \
|
||||||
|
--db-instance-identifier "shieldai-production-db-restored"
|
||||||
|
|
||||||
|
# 4. Update ECS services to point to restored instance
|
||||||
|
# Update DATABASE_URL secret in Secrets Manager
|
||||||
|
aws secretsmanager put-secret-value \
|
||||||
|
--secret-id "shieldai-production-db-password" \
|
||||||
|
--secret-string "$(echo "$DB_SECRET" | jq --arg host "$(aws rds describe-db-instances --db-instance-identifier shieldai-production-db-restored --query 'DBInstances[0].Endpoint.Address' --output text)" '.host = $host')"
|
||||||
|
|
||||||
|
# 5. Trigger ECS service redeployment to pick up new DB endpoint
|
||||||
|
./infra/scripts/rollback.sh production all
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 RDS Snapshot Restore
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. List available snapshots
|
||||||
|
aws rds describe-db-snapshots \
|
||||||
|
--db-instance-identifier "shieldai-production-db"
|
||||||
|
|
||||||
|
# 2. Restore from specific snapshot
|
||||||
|
aws rds restore-db-instance-from-db-snapshot \
|
||||||
|
--db-instance-identifier "shieldai-production-db-restored" \
|
||||||
|
--db-snapshot-identifier "rds:shieldai-production-db-2026-05-08-03-00" \
|
||||||
|
--db-instance-class "db.t3.medium" \
|
||||||
|
--vpc-security-group-ids "$(terraform -chdir=infra/output -raw vpc_security_group_id)"
|
||||||
|
|
||||||
|
# 3. Follow steps 3-5 from Point-in-Time Recovery above
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Automated Rollback Triggers
|
||||||
|
|
||||||
|
### 6.1 CI/CD Health Check Failure
|
||||||
|
|
||||||
|
**Trigger:** Post-deploy health check returns non-200 from `/health`
|
||||||
|
|
||||||
|
**Pipeline job:** `rollback` in `.github/workflows/deploy.yml`
|
||||||
|
|
||||||
|
**Condition:** `if: failure() && needs.health-check.result == 'failure'`
|
||||||
|
|
||||||
|
**Action:** Rolls back all four ECS services to previous task definition
|
||||||
|
|
||||||
|
**Timeout:** Health check retries for 5 minutes before triggering rollback
|
||||||
|
|
||||||
|
### 6.2 ECS Container Health Check
|
||||||
|
|
||||||
|
Each container has an in-container health check defined in the ECS task definition:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"healthCheck": {
|
||||||
|
"command": ["CMD-SHELL", "wget -q --spider http://localhost:{port}/health || exit 1"],
|
||||||
|
"interval": 30,
|
||||||
|
"timeout": 5,
|
||||||
|
"retries": 3,
|
||||||
|
"startPeriod": 60
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Failure consequence:** Container is marked unhealthy after 3 consecutive failures (90 seconds). ALB marks target as unhealthy after 3 failed health checks (90 seconds). Service enters draining state.
|
||||||
|
|
||||||
|
### 6.3 ALB Target Group Health Check
|
||||||
|
|
||||||
|
The ALB performs HTTP health checks against `/health` on each target:
|
||||||
|
|
||||||
|
| Parameter | Value |
|
||||||
|
|-----------|-------|
|
||||||
|
| Interval | 30s |
|
||||||
|
| Timeout | 5s |
|
||||||
|
| Healthy threshold | 3 |
|
||||||
|
| Unhealthy threshold | 3 |
|
||||||
|
| Expected code | 200 |
|
||||||
|
|
||||||
|
### 6.4 CloudWatch Alarms
|
||||||
|
|
||||||
|
The following alarms are configured in `infra/modules/cloudwatch/main.tf`:
|
||||||
|
|
||||||
|
| Alarm | Threshold | Action |
|
||||||
|
|-------|-----------|--------|
|
||||||
|
| ECS CPU >80% | 80% for 2 periods (10min) | SNS notification |
|
||||||
|
| ECS Memory >85% | 85% for 2 periods (10min) | SNS notification |
|
||||||
|
| ALB 5xx >10/min | 10 for 3 periods (3min) | SNS notification |
|
||||||
|
| RDS CPU >75% | 75% for 2 periods (10min) | SNS notification |
|
||||||
|
| RDS Free Storage <500MB | 500MB for 2 periods (10min) | SNS notification |
|
||||||
|
|
||||||
|
**Alarm escalation path:**
|
||||||
|
1. CloudWatch alarm fires
|
||||||
|
2. SNS notification sent to on-call engineer
|
||||||
|
3. Engineer evaluates: if service is degraded, trigger manual rollback
|
||||||
|
4. If root cause is deployment-related, run `./infra/scripts/rollback.sh production all`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Blue-Green Deployment Rollback
|
||||||
|
|
||||||
|
### 7.1 Architecture
|
||||||
|
|
||||||
|
ShieldAI uses ECS services with rolling deployments. Each deployment creates a new task definition revision. The ALB routes traffic to healthy targets only.
|
||||||
|
|
||||||
|
**Rollback mechanism:** ECS `--rollback` flag reverts the service to the previous task definition revision. This is equivalent to a blue-green swap since:
|
||||||
|
|
||||||
|
1. Old task definition (blue) remains registered
|
||||||
|
2. New task definition (green) is deployed
|
||||||
|
3. On rollback, ECS reverts to blue task definition
|
||||||
|
4. ALB automatically routes to healthy (blue) targets
|
||||||
|
|
||||||
|
### 7.2 Blue-Green Rollback Procedure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Check current deployment state
|
||||||
|
aws ecs list-services --cluster shieldai-production
|
||||||
|
aws ecs describe-services --cluster shieldai-production \
|
||||||
|
--services shieldai-production-api \
|
||||||
|
--query 'services[0].deployments'
|
||||||
|
|
||||||
|
# 2. Identify previous deployment
|
||||||
|
# The deployment with status "PRIMARY" is current.
|
||||||
|
# Look for "ACTIVE" deployment with older task definition.
|
||||||
|
|
||||||
|
# 3. Execute rollback (script handles all services)
|
||||||
|
./infra/scripts/rollback.sh production all
|
||||||
|
|
||||||
|
# 4. Verify rollback
|
||||||
|
aws ecs describe-services --cluster shieldai-production \
|
||||||
|
--services shieldai-production-api \
|
||||||
|
--query 'services[0].deployments[?status==`PRIMARY`].taskDefinition'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Docker Compose Blue-Green (Local)
|
||||||
|
|
||||||
|
For local/staging environments using Docker Compose, implement blue-green via service version pinning:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Current deployment uses DOCKER_TAG env var
|
||||||
|
# Rollback by setting DOCKER_TAG to previous version
|
||||||
|
|
||||||
|
# Save current tag
|
||||||
|
CURRENT_TAG=$(grep DOCKER_TAG .env.prod 2>/dev/null | cut -d= -f2 || echo "latest")
|
||||||
|
|
||||||
|
# Rollback to previous
|
||||||
|
export DOCKER_TAG="v1.2.3"
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
# Verify all services
|
||||||
|
docker compose -f docker-compose.prod.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Rollback Decision Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
Is the service responding?
|
||||||
|
├── YES → Is the response correct?
|
||||||
|
│ ├── YES → Monitor, no action needed
|
||||||
|
│ └── NO → Is it a data issue?
|
||||||
|
│ ├── YES → Database Migration Rollback (§5)
|
||||||
|
│ └── NO → ECS Service Rollback (§3)
|
||||||
|
└── NO → Is it a single service or all?
|
||||||
|
├── Single → ECS Service Rollback (§3, specific service)
|
||||||
|
└── All → Full Environment Rollback
|
||||||
|
├── Is DB corrupted?
|
||||||
|
│ ├── YES → RDS Point-in-Time Recovery (§5.2)
|
||||||
|
│ └── NO → ECS Full Rollback + DB Migration Rollback
|
||||||
|
```
|
||||||
|
|
||||||
|
**SLA targets:**
|
||||||
|
- Single service rollback: **< 5 minutes**
|
||||||
|
- Full environment rollback: **< 15 minutes**
|
||||||
|
- Database recovery: **< 30 minutes** (Point-in-Time)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Post-Rollback Verification
|
||||||
|
|
||||||
|
After any rollback, verify the following:
|
||||||
|
|
||||||
|
### 9.1 Service Health
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check all services are healthy
|
||||||
|
for svc in api darkwatch spamshield voiceprint; do
|
||||||
|
PORT=$(case $svc in
|
||||||
|
api) echo 3000;; darkwatch) echo 3001;;
|
||||||
|
spamshield) echo 3002;; voiceprint) echo 3003;;
|
||||||
|
esac)
|
||||||
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
"https://shieldai-${ENVIRONMENT}-alb.us-east-1.elb.amazonaws.com/health")
|
||||||
|
echo "$svc: HTTP $HTTP_CODE"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 ECS Service Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify all services are stable
|
||||||
|
for svc in api darkwatch spamshield voiceprint; do
|
||||||
|
RUNNING=$(aws ecs describe-services \
|
||||||
|
--cluster "shieldai-${ENVIRONMENT}" \
|
||||||
|
--services "shieldai-${ENVIRONMENT}-${svc}" \
|
||||||
|
--query 'services[0].runningCount' --output text)
|
||||||
|
DESIRED=$(aws ecs describe-services \
|
||||||
|
--cluster "shieldai-${ENVIRONMENT}" \
|
||||||
|
--services "shieldai-${ENVIRONMENT}-${svc}" \
|
||||||
|
--query 'services[0].desiredCount' --output text)
|
||||||
|
echo "$svc: $RUNNING/$DESIRED running"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 Database Connectivity
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify database connection
|
||||||
|
aws ecs execute-command \
|
||||||
|
--cluster "shieldai-${ENVIRONMENT}" \
|
||||||
|
--service "shieldai-${ENVIRONMENT}-api" \
|
||||||
|
--command "npx drizzle-kit status" \
|
||||||
|
--interactive --cluster "shieldai-${ENVIRONMENT}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.4 CloudWatch Verification
|
||||||
|
|
||||||
|
1. Navigate to CloudWatch dashboard: `shieldai-${ENVIRONMENT}-dashboard`
|
||||||
|
2. Verify CPU/Memory utilization is within normal range
|
||||||
|
3. Verify ALB 5xx errors have returned to baseline
|
||||||
|
4. Verify no new alarms are in ALARM state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Testing Checklist
|
||||||
|
|
||||||
|
### 10.1 ECS Rollback Test
|
||||||
|
|
||||||
|
- [ ] Deploy a known-bad image (e.g., image with `/health` returning 500)
|
||||||
|
- [ ] Verify CI/CD health check fails within 5 minutes
|
||||||
|
- [ ] Verify `rollback` job triggers automatically
|
||||||
|
- [ ] Verify all four services revert to previous task definition
|
||||||
|
- [ ] Verify health check passes post-rollback
|
||||||
|
- [ ] Verify CloudWatch metrics show recovery
|
||||||
|
|
||||||
|
### 10.2 Manual Script Test
|
||||||
|
|
||||||
|
- [ ] Run `./infra/scripts/rollback.sh staging api` on staging
|
||||||
|
- [ ] Verify single service rolls back correctly
|
||||||
|
- [ ] Run `./infra/scripts/rollback.sh staging all` on staging
|
||||||
|
- [ ] Verify all services roll back correctly
|
||||||
|
- [ ] Verify script exits with code 0 on success
|
||||||
|
- [ ] Verify script exits with code 1 on failure
|
||||||
|
|
||||||
|
### 10.3 Docker Compose Rollback Test
|
||||||
|
|
||||||
|
- [ ] Deploy v2.0.0 of all services via docker-compose.prod.yml
|
||||||
|
- [ ] Rollback to v1.0.0 using DOCKER_TAG override
|
||||||
|
- [ ] Verify all services restart with previous images
|
||||||
|
- [ ] Verify health endpoints respond correctly
|
||||||
|
|
||||||
|
### 10.4 Database Migration Rollback Test
|
||||||
|
|
||||||
|
- [ ] Apply a test migration on staging
|
||||||
|
- [ ] Run migration rollback procedure
|
||||||
|
- [ ] Verify schema matches pre-migration state
|
||||||
|
- [ ] Verify application connects and functions correctly
|
||||||
|
|
||||||
|
### 10.5 RDS Point-in-Time Recovery Test
|
||||||
|
|
||||||
|
- [ ] Create a test RDS instance
|
||||||
|
- [ ] Insert test data
|
||||||
|
- [ ] Restore to point before data insertion
|
||||||
|
- [ ] Verify restored instance has correct data state
|
||||||
|
- [ ] Clean up test instance
|
||||||
|
|
||||||
|
### 10.6 End-to-End Rollback Drills
|
||||||
|
|
||||||
|
| Drill | Frequency | Participants |
|
||||||
|
|-------|-----------|--------------|
|
||||||
|
| ECS service rollback | Monthly | Senior Engineer |
|
||||||
|
| Full environment rollback | Quarterly | Full engineering team |
|
||||||
|
| Database recovery | Quarterly | Senior Engineer + Founding Engineer |
|
||||||
|
| Blue-green rollback | Quarterly | Full engineering team |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Runbook: Emergency Rollback
|
||||||
|
|
||||||
|
### 11.1 Symptoms
|
||||||
|
|
||||||
|
- ALB 5xx error rate > 10/minute for 3+ minutes
|
||||||
|
- CloudWatch alarm: `shieldai-production-alb-5xx` in ALARM state
|
||||||
|
- Customer-reported service degradation
|
||||||
|
|
||||||
|
### 11.2 Immediate Actions (0-5 minutes)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Confirm environment and scope
|
||||||
|
ENVIRONMENT="production"
|
||||||
|
|
||||||
|
# 2. Check service status
|
||||||
|
aws ecs describe-services \
|
||||||
|
--cluster "shieldai-${ENVIRONMENT}" \
|
||||||
|
--services shieldai-${ENVIRONMENT}-api,shieldai-${ENVIRONMENT}-darkwatch,shieldai-${ENVIRONMENT}-spamshield,shieldai-${ENVIRONMENT}-voiceprint \
|
||||||
|
--query 'services[*].{Name:serviceName,Running:runningCount,Desired:desiredCount,Status:status}'
|
||||||
|
|
||||||
|
# 3. Check ALB health
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
"https://shieldai-${ENVIRONMENT}-alb.us-east-1.elb.amazonaws.com/health"
|
||||||
|
|
||||||
|
# 4. Execute rollback
|
||||||
|
./infra/scripts/rollback.sh ${ENVIRONMENT} all
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 Verification (5-10 minutes)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Wait for services to stabilize
|
||||||
|
aws ecs wait services-stable \
|
||||||
|
--cluster "shieldai-${ENVIRONMENT}" \
|
||||||
|
--services shieldai-${ENVIRONMENT}-api,shieldai-${ENVIRONMENT}-darkwatch,shieldai-${ENVIRONMENT}-spamshield,shieldai-${ENVIRONMENT}-voiceprint
|
||||||
|
|
||||||
|
# 2. Verify health endpoint
|
||||||
|
curl -sf "https://shieldai-${ENVIRONMENT}-alb.us-east-1.elb.amazonaws.com/health" \
|
||||||
|
&& echo "Health: OK" || echo "Health: FAIL"
|
||||||
|
|
||||||
|
# 3. Check CloudWatch for recovery
|
||||||
|
# Navigate to CloudWatch dashboard and verify metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.4 Communication Template
|
||||||
|
|
||||||
|
```
|
||||||
|
## Rollback Notification
|
||||||
|
|
||||||
|
**Environment:** production
|
||||||
|
**Time:** $(date -u '+%Y-%m-%d %H:%M UTC')
|
||||||
|
**Trigger:** [ALB 5xx alarm / manual / CI/CD health check]
|
||||||
|
**Action:** Rolled back all services to previous deployment
|
||||||
|
**Status:** [In Progress / Verified / Resolved]
|
||||||
|
**Next steps:** [Post-mortem / monitoring / investigation]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.5 Post-Incident
|
||||||
|
|
||||||
|
1. Create incident ticket with timeline
|
||||||
|
2. Document root cause
|
||||||
|
3. Update runbook if procedure changed
|
||||||
|
4. Schedule post-mortem within 48 hours
|
||||||
|
5. Create follow-up issues for preventive measures
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A: Quick Reference
|
||||||
|
|
||||||
|
| Resource | Command |
|
||||||
|
|----------|---------|
|
||||||
|
| Rollback script | `./infra/scripts/rollback.sh <env> <service\|all>` |
|
||||||
|
| ECS service status | `aws ecs describe-services --cluster shieldai-<env> --services shieldai-<env>-<svc>` |
|
||||||
|
| ALB health check | `curl -s -o /dev/null -w "%{http_code}" https://shieldai-<env>-alb.us-east-1.elb.amazonaws.com/health` |
|
||||||
|
| RDS snapshots | `aws rds describe-db-snapshots --db-instance-identifier shieldai-<env>-db` |
|
||||||
|
| CloudWatch dashboard | `https://us-east-1.console.aws.amazon.com/cloudwatch/home#dashboards/dashboard/shieldai-<env>-dashboard` |
|
||||||
|
| ECS task logs | `aws logs filter-log-events --log-group-name /ecs/shieldai-<env>-<svc>` |
|
||||||
|
|
||||||
|
## Appendix B: Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Required |
|
||||||
|
|----------|-------------|----------|
|
||||||
|
| `AWS_ACCESS_KEY_ID` | IAM user with ECS, RDS permissions | Yes |
|
||||||
|
| `AWS_SECRET_ACCESS_KEY` | IAM secret key | Yes |
|
||||||
|
| `AWS_DEFAULT_REGION` | AWS region (default: us-east-1) | Yes |
|
||||||
|
| `GITHUB_REPOSITORY_OWNER` | GitHub org/user for container registry | Docker Compose only |
|
||||||
|
| `DOCKER_TAG` | Container image tag to deploy | Docker Compose only |
|
||||||
|
| `POSTGRES_PASSWORD` | Database password | Docker Compose only |
|
||||||
57
infra/environments/production/main.tf
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
terraform {
|
||||||
|
backend "s3" {
|
||||||
|
bucket = "shieldai-production-terraform-state"
|
||||||
|
key = "production/terraform.tfstate"
|
||||||
|
region = "us-east-1"
|
||||||
|
encrypt = true
|
||||||
|
dynamodb_table = "shieldai-terraform-locks"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module "shieldai" {
|
||||||
|
source = "../.."
|
||||||
|
|
||||||
|
environment = "production"
|
||||||
|
aws_region = "us-east-1"
|
||||||
|
project_name = "shieldai"
|
||||||
|
vpc_cidr = "10.1.0.0/16"
|
||||||
|
az_count = 3
|
||||||
|
|
||||||
|
db_instance_class = "db.r6g.large"
|
||||||
|
db_multi_az = true
|
||||||
|
db_backup_retention = 14
|
||||||
|
|
||||||
|
elasticache_node_type = "cache.r6g.large"
|
||||||
|
elasticache_num_nodes = 3
|
||||||
|
|
||||||
|
secrets = {
|
||||||
|
HIBP_API_KEY = var.hibp_api_key
|
||||||
|
RESEND_API_KEY = var.resend_api_key
|
||||||
|
SENTRY_DSN = var.sentry_dsn
|
||||||
|
DATADOG_API_KEY = var.datadog_api_key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "hibp_api_key" {
|
||||||
|
description = "Have I Been Pwned API key"
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "resend_api_key" {
|
||||||
|
description = "Resend API key"
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "sentry_dsn" {
|
||||||
|
description = "Sentry DSN"
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "datadog_api_key" {
|
||||||
|
description = "Datadog API key"
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
4
infra/environments/production/terraform.tfvars.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
hibp_api_key = "YOUR_HIBP_API_KEY"
|
||||||
|
resend_api_key = "YOUR_RESEND_API_KEY"
|
||||||
|
sentry_dsn = "YOUR_SENTRY_DSN"
|
||||||
|
datadog_api_key = "YOUR_DATADOG_API_KEY"
|
||||||
57
infra/environments/staging/main.tf
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
terraform {
|
||||||
|
backend "s3" {
|
||||||
|
bucket = "shieldai-staging-terraform-state"
|
||||||
|
key = "staging/terraform.tfstate"
|
||||||
|
region = "us-east-1"
|
||||||
|
encrypt = true
|
||||||
|
dynamodb_table = "shieldai-terraform-locks"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module "shieldai" {
|
||||||
|
source = "../.."
|
||||||
|
|
||||||
|
environment = "staging"
|
||||||
|
aws_region = "us-east-1"
|
||||||
|
project_name = "shieldai"
|
||||||
|
vpc_cidr = "10.0.0.0/16"
|
||||||
|
az_count = 2
|
||||||
|
|
||||||
|
db_instance_class = "db.t3.medium"
|
||||||
|
db_multi_az = false
|
||||||
|
db_backup_retention = 3
|
||||||
|
|
||||||
|
elasticache_node_type = "cache.t3.small"
|
||||||
|
elasticache_num_nodes = 1
|
||||||
|
|
||||||
|
secrets = {
|
||||||
|
HIBP_API_KEY = var.hibp_api_key
|
||||||
|
RESEND_API_KEY = var.resend_api_key
|
||||||
|
SENTRY_DSN = var.sentry_dsn
|
||||||
|
DATADOG_API_KEY = var.datadog_api_key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "hibp_api_key" {
|
||||||
|
description = "Have I Been Pwned API key"
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "resend_api_key" {
|
||||||
|
description = "Resend API key"
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "sentry_dsn" {
|
||||||
|
description = "Sentry DSN"
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "datadog_api_key" {
|
||||||
|
description = "Datadog API key"
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
4
infra/environments/staging/terraform.tfvars.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
hibp_api_key = "YOUR_HIBP_API_KEY"
|
||||||
|
resend_api_key = "YOUR_RESEND_API_KEY"
|
||||||
|
sentry_dsn = "YOUR_SENTRY_DSN"
|
||||||
|
datadog_api_key = "YOUR_DATADOG_API_KEY"
|
||||||
61
infra/load-tests/README.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# ShieldAI Load Tests
|
||||||
|
|
||||||
|
k6 load testing suite for ShieldAI services.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- k6 v0.45+ installed
|
||||||
|
- Target services running on staging environment
|
||||||
|
- Authentication tokens for API access
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Local Execution
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run against local development environment
|
||||||
|
k6 run --env BASE_URL=http://localhost:3000 --env AUTH_TOKEN=dev-token src/darkwatch.js
|
||||||
|
|
||||||
|
# Run with results output
|
||||||
|
k6 run --out json=results.json src/darkwatch.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI/CD Execution
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run on staging environment
|
||||||
|
k6 run --env BASE_URL=https://staging-api.freno.me --env AUTH_TOKEN=$STAGING_AUTH_TOKEN src/darkwatch.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Configuration
|
||||||
|
|
||||||
|
Each test script includes:
|
||||||
|
|
||||||
|
- **Stages**: Ramp-up, sustained load, ramp-down
|
||||||
|
- **Thresholds**: P99 latency and error rate limits
|
||||||
|
- **Metrics**: Custom metrics for error tracking
|
||||||
|
|
||||||
|
### Current Thresholds
|
||||||
|
|
||||||
|
| Service | P99 Latency | Error Rate |
|
||||||
|
|---------|-------------|------------|
|
||||||
|
| Darkwatch | < 200ms | < 1% |
|
||||||
|
|
||||||
|
## Metrics Collection
|
||||||
|
|
||||||
|
Run with output options:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# JSON output for analysis
|
||||||
|
k6 run --out json=darkwatch-results.json src/darkwatch.js
|
||||||
|
|
||||||
|
# InfluxDB for visualization
|
||||||
|
k6 run --out influxdb=http://influxdb:8086/k6 src/darkwatch.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Create load test scripts for Spamshield and Voiceprint
|
||||||
|
2. Integrate with GitHub Actions CI pipeline
|
||||||
|
3. Set up metrics visualization dashboard
|
||||||
|
4. Configure alerting on threshold breaches
|
||||||
99
infra/load-tests/src/darkwatch.js
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import http from 'k6/http';
|
||||||
|
import { check, group } from 'k6';
|
||||||
|
import { Rate } from 'k6/metrics';
|
||||||
|
|
||||||
|
// Test configuration
|
||||||
|
export const options = {
|
||||||
|
stages: [
|
||||||
|
{ duration: '30s', target: 100 }, // Ramp up to 100 users
|
||||||
|
{ duration: '2m', target: 500 }, // Ramp to 500 req/s
|
||||||
|
{ duration: '3m', target: 500 }, // Stay at 500 req/s for 3 minutes
|
||||||
|
{ duration: '30s', target: 0 }, // Ramp down to 0
|
||||||
|
],
|
||||||
|
thresholds: {
|
||||||
|
http_req_duration: ['p(99)<200'], // P99 latency < 200ms
|
||||||
|
errors: ['rate<0.01'], // Error rate < 1%
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
group('Watchlist Operations', function () {
|
||||||
|
// GET /watchlist
|
||||||
|
const watchlistRes = http.get(`${BASE_URL}/watchlist`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${getAuthToken()}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
check(watchlistRes, {
|
||||||
|
'watchlist GET status is 200': (r) => r.status === 200,
|
||||||
|
'watchlist GET P99 < 100ms': (r) => r.timings.duration < 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /watchlist
|
||||||
|
const newItemRes = http.post(
|
||||||
|
`${BASE_URL}/watchlist`,
|
||||||
|
JSON.stringify({ type: 'email', value: `test${Date()}@example.com` }),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${getAuthToken()}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
check(newItemRes, {
|
||||||
|
'watchlist POST status is 201': (r) => r.status === 201,
|
||||||
|
'watchlist POST P99 < 200ms': (r) => r.timings.duration < 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /scan
|
||||||
|
const scanRes = http.post(
|
||||||
|
`${BASE_URL}/scan`,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: { 'Authorization': `Bearer ${getAuthToken()}` },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
check(scanRes, {
|
||||||
|
'scan POST status is 200': (r) => r.status === 200,
|
||||||
|
'scan POST P99 < 150ms': (r) => r.timings.duration < 150,
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /scan/schedule
|
||||||
|
const scheduleRes = http.get(`${BASE_URL}/scan/schedule`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${getAuthToken()}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
check(scheduleRes, {
|
||||||
|
'schedule GET status is 200': (r) => r.status === 200,
|
||||||
|
'schedule GET P99 < 100ms': (r) => r.timings.duration < 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /exposures
|
||||||
|
const exposuresRes = http.get(`${BASE_URL}/exposures`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${getAuthToken()}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
check(exposuresRes, {
|
||||||
|
'exposures GET status is 200': (r) => r.status === 200,
|
||||||
|
'exposures GET P99 < 150ms': (r) => r.timings.duration < 150,
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /alerts
|
||||||
|
const alertsRes = http.get(`${BASE_URL}/alerts`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${getAuthToken()}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
check(alertsRes, {
|
||||||
|
'alerts GET status is 200': (r) => r.status === 200,
|
||||||
|
'alerts GET P99 < 150ms': (r) => r.timings.duration < 150,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get auth token (replace with actual token retrieval)
|
||||||
|
function getAuthToken() {
|
||||||
|
return __ENV.AUTH_TOKEN || 'test-token';
|
||||||
|
}
|
||||||
113
infra/main.tf
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
terraform {
|
||||||
|
required_version = ">= 1.5.0"
|
||||||
|
|
||||||
|
required_providers {
|
||||||
|
aws = {
|
||||||
|
source = "hashicorp/aws"
|
||||||
|
version = "~> 5.30"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
backend "s3" {
|
||||||
|
bucket = "shieldai-terraform-state"
|
||||||
|
key = "global/terraform.tfstate"
|
||||||
|
region = "us-east-1"
|
||||||
|
encrypt = true
|
||||||
|
dynamodb_table = "shieldai-terraform-locks"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "aws" {
|
||||||
|
region = var.aws_region
|
||||||
|
|
||||||
|
default_tags {
|
||||||
|
tags = {
|
||||||
|
Project = "ShieldAI"
|
||||||
|
ManagedBy = "terraform"
|
||||||
|
Environment = var.environment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module "vpc" {
|
||||||
|
source = "./modules/vpc"
|
||||||
|
|
||||||
|
environment = var.environment
|
||||||
|
vpc_cidr = var.vpc_cidr
|
||||||
|
az_count = var.az_count
|
||||||
|
project_name = var.project_name
|
||||||
|
kms_key_arn = module.ecs.kms_key_arn
|
||||||
|
}
|
||||||
|
|
||||||
|
module "ecs" {
|
||||||
|
source = "./modules/ecs"
|
||||||
|
|
||||||
|
environment = var.environment
|
||||||
|
cluster_name = "${var.project_name}-${var.environment}"
|
||||||
|
vpc_id = module.vpc.vpc_id
|
||||||
|
subnet_ids = module.vpc.private_subnet_ids
|
||||||
|
public_subnet_ids = module.vpc.public_subnet_ids
|
||||||
|
security_group_ids = [module.vpc.ecs_security_group_id]
|
||||||
|
alb_security_group_id = module.vpc.alb_security_group_id
|
||||||
|
services = var.services
|
||||||
|
container_images = var.container_images
|
||||||
|
secrets_arn = module.secrets.secrets_manager_arn
|
||||||
|
cache_cluster_arn = module.elasticache.replication_group_arn
|
||||||
|
domain_name = var.domain_name
|
||||||
|
}
|
||||||
|
|
||||||
|
module "rds" {
|
||||||
|
source = "./modules/rds"
|
||||||
|
|
||||||
|
environment = var.environment
|
||||||
|
vpc_id = module.vpc.vpc_id
|
||||||
|
subnet_ids = module.vpc.private_subnet_ids
|
||||||
|
security_group_id = module.vpc.rds_security_group_id
|
||||||
|
db_name = var.db_name
|
||||||
|
db_instance_class = var.db_instance_class
|
||||||
|
multi_az = var.db_multi_az
|
||||||
|
backup_retention = var.db_backup_retention
|
||||||
|
project_name = var.project_name
|
||||||
|
}
|
||||||
|
|
||||||
|
module "elasticache" {
|
||||||
|
source = "./modules/elasticache"
|
||||||
|
|
||||||
|
environment = var.environment
|
||||||
|
vpc_id = module.vpc.vpc_id
|
||||||
|
subnet_ids = module.vpc.private_subnet_ids
|
||||||
|
security_group_id = module.vpc.elasticache_security_group_id
|
||||||
|
node_type = var.elasticache_node_type
|
||||||
|
num_nodes = var.elasticache_num_nodes
|
||||||
|
project_name = var.project_name
|
||||||
|
}
|
||||||
|
|
||||||
|
module "s3" {
|
||||||
|
source = "./modules/s3"
|
||||||
|
|
||||||
|
environment = var.environment
|
||||||
|
project_name = var.project_name
|
||||||
|
}
|
||||||
|
|
||||||
|
module "secrets" {
|
||||||
|
source = "./modules/secrets"
|
||||||
|
|
||||||
|
environment = var.environment
|
||||||
|
project_name = var.project_name
|
||||||
|
rds_endpoint = module.rds.db_endpoint
|
||||||
|
db_password = module.rds.db_password
|
||||||
|
elasticache_endpoint = module.elasticache.cache_endpoint
|
||||||
|
redis_auth_token = module.elasticache.auth_token
|
||||||
|
secrets = var.secrets
|
||||||
|
}
|
||||||
|
|
||||||
|
module "cloudwatch" {
|
||||||
|
source = "./modules/cloudwatch"
|
||||||
|
|
||||||
|
environment = var.environment
|
||||||
|
cluster_name = "${var.project_name}-${var.environment}"
|
||||||
|
project_name = var.project_name
|
||||||
|
rds_identifier = module.rds.db_instance_identifier
|
||||||
|
cache_endpoint = module.elasticache.cache_endpoint
|
||||||
|
}
|
||||||
464
infra/modules/cloudwatch/main.tf
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
variable "environment" {
|
||||||
|
description = "Deployment environment"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "cluster_name" {
|
||||||
|
description = "ECS cluster name"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "project_name" {
|
||||||
|
description = "Project name"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "rds_identifier" {
|
||||||
|
description = "RDS instance identifier"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "cache_endpoint" {
|
||||||
|
description = "ElastiCache endpoint"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "alert_email" {
|
||||||
|
description = "Email address for alert notifications"
|
||||||
|
type = string
|
||||||
|
default = "ops@shieldai.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_sns_topic" "alerts" {
|
||||||
|
name = "${var.project_name}-${var.environment}-alerts"
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Environment = var.environment
|
||||||
|
Project = var.project_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_sns_topic_subscription" "alerts_email" {
|
||||||
|
topic_arn = aws_sns_topic.alerts.arn
|
||||||
|
protocol = "email"
|
||||||
|
endpoint = var.alert_email
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_dashboard" "main" {
|
||||||
|
dashboard_name = "${var.project_name}-${var.environment}-dashboard"
|
||||||
|
|
||||||
|
dashboard_body = jsonencode({
|
||||||
|
widgets = [
|
||||||
|
{
|
||||||
|
type = "metric"
|
||||||
|
properties = {
|
||||||
|
title = "ECS CPU Utilization"
|
||||||
|
metrics = [
|
||||||
|
["AWS/ECS", "CPUUtilization", "ClusterName", var.cluster_name]
|
||||||
|
]
|
||||||
|
view = "timeSeries"
|
||||||
|
stacked = false
|
||||||
|
region = "us-east-1"
|
||||||
|
period = 300
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type = "metric"
|
||||||
|
properties = {
|
||||||
|
title = "ECS Memory Utilization"
|
||||||
|
metrics = [
|
||||||
|
["AWS/ECS", "MemoryUtilization", "ClusterName", var.cluster_name]
|
||||||
|
]
|
||||||
|
view = "timeSeries"
|
||||||
|
stacked = false
|
||||||
|
region = "us-east-1"
|
||||||
|
period = 300
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type = "metric"
|
||||||
|
properties = {
|
||||||
|
title = "RDS CPU Utilization"
|
||||||
|
metrics = [
|
||||||
|
["AWS/RDS", "CPUUtilization", "DBInstanceIdentifier", var.rds_identifier]
|
||||||
|
]
|
||||||
|
view = "timeSeries"
|
||||||
|
stacked = false
|
||||||
|
region = "us-east-1"
|
||||||
|
period = 300
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type = "metric"
|
||||||
|
properties = {
|
||||||
|
title = "ALB Request Count"
|
||||||
|
metrics = [
|
||||||
|
["AWS/ApplicationELB", "RequestCount", "LoadBalancer", "${var.cluster_name}-alb"]
|
||||||
|
]
|
||||||
|
view = "timeSeries"
|
||||||
|
stacked = false
|
||||||
|
region = "us-east-1"
|
||||||
|
period = 60
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type = "metric"
|
||||||
|
properties = {
|
||||||
|
title = "ALB 5xx Errors"
|
||||||
|
metrics = [
|
||||||
|
["AWS/ApplicationELB", "HTTPCode_Elb_5XX_Count", "LoadBalancer", "${var.cluster_name}-alb"]
|
||||||
|
]
|
||||||
|
view = "timeSeries"
|
||||||
|
stacked = false
|
||||||
|
region = "us-east-1"
|
||||||
|
period = 60
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type = "metric"
|
||||||
|
properties = {
|
||||||
|
title = "P99 Latency (Target Group)"
|
||||||
|
metrics = [
|
||||||
|
["AWS/ApplicationELB", "TargetResponseTime", "LoadBalancer", "${var.cluster_name}-alb", "Statistic", "p99"],
|
||||||
|
["AWS/ApplicationELB", "TargetResponseTime", "LoadBalancer", "${var.cluster_name}-alb", "Statistic", "p95"]
|
||||||
|
]
|
||||||
|
view = "timeSeries"
|
||||||
|
stacked = false
|
||||||
|
region = "us-east-1"
|
||||||
|
period = 60
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type = "metric"
|
||||||
|
properties = {
|
||||||
|
title = "Error Rate (5xx / Total)"
|
||||||
|
metrics = [
|
||||||
|
["AWS/ApplicationELB", "HTTPCode_Elb_5XX_Count", "LoadBalancer", "${var.cluster_name}-alb"],
|
||||||
|
["AWS/ApplicationELB", "HTTPCode_Elb_4XX_Count", "LoadBalancer", "${var.cluster_name}-alb"]
|
||||||
|
]
|
||||||
|
view = "timeSeries"
|
||||||
|
stacked = false
|
||||||
|
region = "us-east-1"
|
||||||
|
period = 60
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type = "metric"
|
||||||
|
properties = {
|
||||||
|
title = "Throughput (Request Count)"
|
||||||
|
metrics = [
|
||||||
|
["AWS/ApplicationELB", "RequestCount", "LoadBalancer", "${var.cluster_name}-alb"]
|
||||||
|
]
|
||||||
|
view = "timeSeries"
|
||||||
|
stacked = false
|
||||||
|
region = "us-east-1"
|
||||||
|
period = 60
|
||||||
|
yAxis = {
|
||||||
|
left = {
|
||||||
|
label = "Requests/sec"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type = "metric"
|
||||||
|
properties = {
|
||||||
|
title = "API Latency Percentiles"
|
||||||
|
metrics = [
|
||||||
|
["ShieldAI", "api_latency", "service", "api", "percentile", "p99", "statistic", "Average"],
|
||||||
|
["ShieldAI", "api_latency", "service", "api", "percentile", "p95", "statistic", "Average"],
|
||||||
|
["ShieldAI", "api_latency", "service", "api", "percentile", "p50", "statistic", "Average"]
|
||||||
|
]
|
||||||
|
view = "timeSeries"
|
||||||
|
stacked = false
|
||||||
|
region = "us-east-1"
|
||||||
|
period = 60
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type = "metric"
|
||||||
|
properties = {
|
||||||
|
title = "API Error Rate"
|
||||||
|
metrics = [
|
||||||
|
["ShieldAI", "api_errors", "service", "api", "statistic", "Sum"]
|
||||||
|
]
|
||||||
|
view = "timeSeries"
|
||||||
|
stacked = false
|
||||||
|
region = "us-east-1"
|
||||||
|
period = 60
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type = "metric"
|
||||||
|
properties = {
|
||||||
|
title = "API Throughput"
|
||||||
|
metrics = [
|
||||||
|
["ShieldAI", "api_requests", "service", "api", "statistic", "Sum"]
|
||||||
|
]
|
||||||
|
view = "timeSeries"
|
||||||
|
stacked = false
|
||||||
|
region = "us-east-1"
|
||||||
|
period = 60
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type = "metric"
|
||||||
|
properties = {
|
||||||
|
title = "ECS Running Tasks"
|
||||||
|
metrics = [
|
||||||
|
["AWS/ECS", "RunningTaskCount", "ClusterName", var.cluster_name]
|
||||||
|
]
|
||||||
|
view = "timeSeries"
|
||||||
|
stacked = false
|
||||||
|
region = "us-east-1"
|
||||||
|
period = 60
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type = "metric"
|
||||||
|
properties = {
|
||||||
|
title = "RDS Read/Write IOPS"
|
||||||
|
metrics = [
|
||||||
|
["AWS/RDS", "ReadIOPS", "DBInstanceIdentifier", var.rds_identifier],
|
||||||
|
["AWS/RDS", "WriteIOPS", "DBInstanceIdentifier", var.rds_identifier]
|
||||||
|
]
|
||||||
|
view = "timeSeries"
|
||||||
|
stacked = false
|
||||||
|
region = "us-east-1"
|
||||||
|
period = 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "ecs_cpu_high" {
|
||||||
|
alarm_name = "${var.project_name}-${var.environment}-ecs-cpu-high"
|
||||||
|
comparison_operator = "GreaterThanThreshold"
|
||||||
|
evaluation_periods = 2
|
||||||
|
metric_name = "CPUUtilization"
|
||||||
|
namespace = "AWS/ECS"
|
||||||
|
period = 300
|
||||||
|
statistic = "Average"
|
||||||
|
threshold = 80
|
||||||
|
alarm_description = "ECS CPU utilization above 80%"
|
||||||
|
alarm_actions = [aws_sns_topic.alerts.arn]
|
||||||
|
|
||||||
|
dimensions = {
|
||||||
|
ClusterName = var.cluster_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "ecs_memory_high" {
|
||||||
|
alarm_name = "${var.project_name}-${var.environment}-ecs-memory-high"
|
||||||
|
comparison_operator = "GreaterThanThreshold"
|
||||||
|
evaluation_periods = 2
|
||||||
|
metric_name = "MemoryUtilization"
|
||||||
|
namespace = "AWS/ECS"
|
||||||
|
period = 300
|
||||||
|
statistic = "Average"
|
||||||
|
threshold = 85
|
||||||
|
alarm_description = "ECS memory utilization above 85%"
|
||||||
|
alarm_actions = [aws_sns_topic.alerts.arn]
|
||||||
|
|
||||||
|
dimensions = {
|
||||||
|
ClusterName = var.cluster_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "alb_5xx" {
|
||||||
|
alarm_name = "${var.project_name}-${var.environment}-alb-5xx"
|
||||||
|
comparison_operator = "GreaterThanThreshold"
|
||||||
|
evaluation_periods = 3
|
||||||
|
metric_name = "HTTPCode_Elb_5XX_Count"
|
||||||
|
namespace = "AWS/ApplicationELB"
|
||||||
|
period = 60
|
||||||
|
statistic = "Sum"
|
||||||
|
threshold = 10
|
||||||
|
alarm_description = "ALB 5xx errors above 10 per minute"
|
||||||
|
alarm_actions = [aws_sns_topic.alerts.arn]
|
||||||
|
|
||||||
|
dimensions = {
|
||||||
|
LoadBalancer = "${var.cluster_name}-alb"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "rds_cpu_high" {
|
||||||
|
alarm_name = "${var.project_name}-${var.environment}-rds-cpu-high"
|
||||||
|
comparison_operator = "GreaterThanThreshold"
|
||||||
|
evaluation_periods = 2
|
||||||
|
metric_name = "CPUUtilization"
|
||||||
|
namespace = "AWS/RDS"
|
||||||
|
period = 300
|
||||||
|
statistic = "Average"
|
||||||
|
threshold = 75
|
||||||
|
alarm_description = "RDS CPU utilization above 75%"
|
||||||
|
alarm_actions = [aws_sns_topic.alerts.arn]
|
||||||
|
|
||||||
|
dimensions = {
|
||||||
|
DBInstanceIdentifier = var.rds_identifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "rds_free_storage" {
|
||||||
|
alarm_name = "${var.project_name}-${var.environment}-rds-free-storage"
|
||||||
|
comparison_operator = "LessThanThreshold"
|
||||||
|
evaluation_periods = 2
|
||||||
|
metric_name = "FreeStorageSpace"
|
||||||
|
namespace = "AWS/RDS"
|
||||||
|
period = 300
|
||||||
|
statistic = "Average"
|
||||||
|
threshold = 524288000
|
||||||
|
alarm_description = "RDS free storage below 500MB"
|
||||||
|
alarm_actions = [aws_sns_topic.alerts.arn]
|
||||||
|
|
||||||
|
dimensions = {
|
||||||
|
DBInstanceIdentifier = var.rds_identifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "p99_latency_high" {
|
||||||
|
alarm_name = "${var.project_name}-${var.environment}-p99-latency-high"
|
||||||
|
comparison_operator = "GreaterThanThreshold"
|
||||||
|
evaluation_periods = 3
|
||||||
|
metric_name = "TargetResponseTime"
|
||||||
|
namespace = "AWS/ApplicationELB"
|
||||||
|
period = 60
|
||||||
|
statistic = "p99"
|
||||||
|
threshold = 2
|
||||||
|
alarm_description = "P99 latency above 2 seconds"
|
||||||
|
alarm_actions = [aws_sns_topic.alerts.arn]
|
||||||
|
|
||||||
|
dimensions = {
|
||||||
|
LoadBalancer = "${var.cluster_name}-alb"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "error_rate_high" {
|
||||||
|
alarm_name = "${var.project_name}-${var.environment}-error-rate-high"
|
||||||
|
comparison_operator = "GreaterThanThreshold"
|
||||||
|
evaluation_periods = 3
|
||||||
|
metric_name = "HTTPCode_Elb_5XX_Count"
|
||||||
|
namespace = "AWS/ApplicationELB"
|
||||||
|
period = 60
|
||||||
|
statistic = "Sum"
|
||||||
|
threshold = 5
|
||||||
|
alarm_description = "Error rate above 5 errors per minute"
|
||||||
|
alarm_actions = [aws_sns_topic.alerts.arn]
|
||||||
|
|
||||||
|
dimensions = {
|
||||||
|
LoadBalancer = "${var.cluster_name}-alb"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "throughput_low" {
|
||||||
|
alarm_name = "${var.project_name}-${var.environment}-throughput-low"
|
||||||
|
comparison_operator = "LessThanThreshold"
|
||||||
|
evaluation_periods = 5
|
||||||
|
metric_name = "RequestCount"
|
||||||
|
namespace = "AWS/ApplicationELB"
|
||||||
|
period = 60
|
||||||
|
statistic = "Sum"
|
||||||
|
threshold = 10
|
||||||
|
alarm_description = "Throughput below 10 requests per minute"
|
||||||
|
alarm_actions = [aws_sns_topic.alerts.arn]
|
||||||
|
|
||||||
|
dimensions = {
|
||||||
|
LoadBalancer = "${var.cluster_name}-alb"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_log_group" "api" {
|
||||||
|
name = "/${var.project_name}/${var.environment}/api"
|
||||||
|
retention_in_days = 30
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Environment = var.environment
|
||||||
|
Project = var.project_name
|
||||||
|
Service = "api"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_log_group" "datadog" {
|
||||||
|
name = "/${var.project_name}/${var.environment}/datadog"
|
||||||
|
retention_in_days = 30
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Environment = var.environment
|
||||||
|
Project = var.project_name
|
||||||
|
Service = "datadog"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_log_group" "sentry" {
|
||||||
|
name = "/${var.project_name}/${var.environment}/sentry"
|
||||||
|
retention_in_days = 30
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Environment = var.environment
|
||||||
|
Project = var.project_name
|
||||||
|
Service = "sentry"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "app_p99_latency_high" {
|
||||||
|
alarm_name = "${var.project_name}-${var.environment}-app-p99-latency-high"
|
||||||
|
comparison_operator = "GreaterThanThreshold"
|
||||||
|
evaluation_periods = 3
|
||||||
|
metric_name = "api_latency"
|
||||||
|
namespace = "ShieldAI"
|
||||||
|
period = 60
|
||||||
|
statistic = "Average"
|
||||||
|
threshold = 2000
|
||||||
|
alarm_description = "Application P99 latency above 2000ms"
|
||||||
|
alarm_actions = [aws_sns_topic.alerts.arn]
|
||||||
|
|
||||||
|
dimensions = {
|
||||||
|
service = "api"
|
||||||
|
percentile = "p99"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "app_error_rate_high" {
|
||||||
|
alarm_name = "${var.project_name}-${var.environment}-app-error-rate-high"
|
||||||
|
comparison_operator = "GreaterThanThreshold"
|
||||||
|
evaluation_periods = 3
|
||||||
|
metric_name = "api_errors"
|
||||||
|
namespace = "ShieldAI"
|
||||||
|
period = 60
|
||||||
|
statistic = "Sum"
|
||||||
|
threshold = 10
|
||||||
|
alarm_description = "Application error count above 10 per minute"
|
||||||
|
alarm_actions = [aws_sns_topic.alerts.arn]
|
||||||
|
|
||||||
|
dimensions = {
|
||||||
|
service = "api"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "app_throughput_low" {
|
||||||
|
alarm_name = "${var.project_name}-${var.environment}-app-throughput-low"
|
||||||
|
comparison_operator = "LessThanThreshold"
|
||||||
|
evaluation_periods = 5
|
||||||
|
metric_name = "api_requests"
|
||||||
|
namespace = "ShieldAI"
|
||||||
|
period = 60
|
||||||
|
statistic = "Sum"
|
||||||
|
threshold = 10
|
||||||
|
alarm_description = "Application throughput below 10 requests per minute"
|
||||||
|
alarm_actions = [aws_sns_topic.alerts.arn]
|
||||||
|
|
||||||
|
dimensions = {
|
||||||
|
service = "api"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output "dashboard_url" {
|
||||||
|
description = "CloudWatch dashboard URL"
|
||||||
|
value = "https://us-east-1.console.aws.amazon.com/cloudwatch/home#dashboards/dashboard/${var.project_name}-${var.environment}-dashboard"
|
||||||
|
}
|
||||||
|
|
||||||
|
output "sns_topic_arn" {
|
||||||
|
description = "SNS topic ARN for alerts"
|
||||||
|
value = aws_sns_topic.alerts.arn
|
||||||
|
}
|
||||||
519
infra/modules/ecs/main.tf
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
variable "environment" {
|
||||||
|
description = "Deployment environment"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "cluster_name" {
|
||||||
|
description = "ECS cluster name"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vpc_id" {
|
||||||
|
description = "VPC ID"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "subnet_ids" {
|
||||||
|
description = "Private subnet IDs for ECS tasks"
|
||||||
|
type = list(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "public_subnet_ids" {
|
||||||
|
description = "Public subnet IDs for ALB"
|
||||||
|
type = list(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "security_group_ids" {
|
||||||
|
description = "Security group IDs"
|
||||||
|
type = list(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "alb_security_group_id" {
|
||||||
|
description = "ALB security group ID"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "services" {
|
||||||
|
description = "ECS services to deploy"
|
||||||
|
type = map(object({
|
||||||
|
cpu = number
|
||||||
|
memory = number
|
||||||
|
port = number
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "container_images" {
|
||||||
|
description = "Container image tags"
|
||||||
|
type = map(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "secrets_arn" {
|
||||||
|
description = "Secrets Manager ARN"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "cache_cluster_arn" {
|
||||||
|
description = "ElastiCache replication group ARN"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "domain_name" {
|
||||||
|
description = "Route53 hosted zone domain for ACM cert validation"
|
||||||
|
type = string
|
||||||
|
default = "shieldai.app"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ecs_cluster" "main" {
|
||||||
|
name = var.cluster_name
|
||||||
|
|
||||||
|
settings {
|
||||||
|
name = "containerInsights"
|
||||||
|
value = "enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = var.cluster_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ecs_cluster_capacity_providers" "main" {
|
||||||
|
cluster_name = aws_ecs_cluster.main.name
|
||||||
|
|
||||||
|
capacity_providers = ["FARGATE"]
|
||||||
|
|
||||||
|
default_capacity_provider_strategy {
|
||||||
|
base = 1
|
||||||
|
weight = 100
|
||||||
|
capacity_provider = "FARGATE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ecs_task_definition" "services" {
|
||||||
|
for_each = var.services
|
||||||
|
|
||||||
|
family = "${var.cluster_name}-${each.key}"
|
||||||
|
|
||||||
|
container_definitions = jsonencode([
|
||||||
|
{
|
||||||
|
name = each.key
|
||||||
|
image = "ghcr.io/shieldai/shieldai-${each.key}:${var.container_images[each.key]}"
|
||||||
|
cpu = each.cpu
|
||||||
|
memory = each.memory
|
||||||
|
essential = true
|
||||||
|
|
||||||
|
portMappings = [
|
||||||
|
{
|
||||||
|
containerPort = each.port
|
||||||
|
hostPort = each.port
|
||||||
|
protocol = "tcp"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
environment = [
|
||||||
|
{
|
||||||
|
name = "NODE_ENV"
|
||||||
|
value = var.environment
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "PORT"
|
||||||
|
value = tostring(each.port)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "DD_ENV"
|
||||||
|
value = var.environment
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "DD_SERVICE"
|
||||||
|
value = "${var.cluster_name}-${each.key}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "DD_VERSION"
|
||||||
|
value = var.container_images[each.key]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "DD_TRACE_ENABLED"
|
||||||
|
value = "true"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "DD_LOGS_INJECTION"
|
||||||
|
value = "true"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "DD_AGENT_HOST"
|
||||||
|
value = "localhost"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "DD_AGENT_PORT"
|
||||||
|
value = "8126"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "SENTRY_ENVIRONMENT"
|
||||||
|
value = var.environment
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "SENTRY_RELEASE"
|
||||||
|
value = var.container_images[each.key]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "AWS_REGION"
|
||||||
|
value = "us-east-1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "DD_SITE"
|
||||||
|
value = "datadoghq.com"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
secrets = [
|
||||||
|
{
|
||||||
|
name = "DATABASE_URL"
|
||||||
|
valueFrom = "${var.secrets_arn}:DATABASE_URL::"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "REDIS_URL"
|
||||||
|
valueFrom = "${var.secrets_arn}:REDIS_URL::"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "HIBP_API_KEY"
|
||||||
|
valueFrom = "${var.secrets_arn}:HIBP_API_KEY::"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "RESEND_API_KEY"
|
||||||
|
valueFrom = "${var.secrets_arn}:RESEND_API_KEY::"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "SENTRY_DSN"
|
||||||
|
valueFrom = "${var.secrets_arn}:SENTRY_DSN::"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "DD_API_KEY"
|
||||||
|
valueFrom = "${var.secrets_arn}:DD_API_KEY::"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
logConfiguration = {
|
||||||
|
logDriver = "awslogs"
|
||||||
|
options = {
|
||||||
|
"awslogs-group" = "/ecs/${var.cluster_name}-${each.key}"
|
||||||
|
"awslogs-region" = "us-east-1"
|
||||||
|
"awslogs-stream-prefix" = each.key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
healthCheck = {
|
||||||
|
command = ["CMD-SHELL", "curl -f http://localhost:${each.port}/health || exit 1"]
|
||||||
|
interval = 30
|
||||||
|
timeout = 5
|
||||||
|
retries = 3
|
||||||
|
startPeriod = 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
network_mode = "awsvpc"
|
||||||
|
memory = each.memory
|
||||||
|
cpu = each.cpu
|
||||||
|
requires_compatibilities = ["FARGATE"]
|
||||||
|
|
||||||
|
execution_role_arn = aws_iam_role.execution[each.key].arn
|
||||||
|
task_role_arn = aws_iam_role.task[each.key].arn
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.cluster_name}-${each.key}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role" "execution" {
|
||||||
|
for_each = var.services
|
||||||
|
|
||||||
|
name = "${var.cluster_name}-${each.key}-execution"
|
||||||
|
|
||||||
|
assume_role_policy = jsonencode({
|
||||||
|
Version = "2012-10-17"
|
||||||
|
Statement = [
|
||||||
|
{
|
||||||
|
Action = "sts:AssumeRole"
|
||||||
|
Effect = "Allow"
|
||||||
|
Principal = {
|
||||||
|
Service = "ecs-tasks.amazonaws.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
managed_policy_arns = [
|
||||||
|
"arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role" "task" {
|
||||||
|
for_each = var.services
|
||||||
|
|
||||||
|
name = "${var.cluster_name}-${each.key}-task"
|
||||||
|
|
||||||
|
assume_role_policy = jsonencode({
|
||||||
|
Version = "2012-10-17"
|
||||||
|
Statement = [
|
||||||
|
{
|
||||||
|
Action = "sts:AssumeRole"
|
||||||
|
Effect = "Allow"
|
||||||
|
Principal = {
|
||||||
|
Service = "ecs-tasks.amazonaws.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
inline_policy {
|
||||||
|
name = "secrets-manager-access"
|
||||||
|
policy = jsonencode({
|
||||||
|
Version = "2012-10-17"
|
||||||
|
Statement = [
|
||||||
|
{
|
||||||
|
Effect = "Allow"
|
||||||
|
Action = [
|
||||||
|
"secretsmanager:GetSecretValue",
|
||||||
|
"secretsmanager:DescribeSecret"
|
||||||
|
]
|
||||||
|
Resource = var.secrets_arn
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
inline_policy {
|
||||||
|
name = "elasticache-access"
|
||||||
|
policy = jsonencode({
|
||||||
|
Version = "2012-10-17"
|
||||||
|
Statement = [
|
||||||
|
{
|
||||||
|
Effect = "Allow"
|
||||||
|
Action = [
|
||||||
|
"elasticache:DescribeCacheClusters",
|
||||||
|
"elasticache:DescribeCacheSubnetGroups"
|
||||||
|
]
|
||||||
|
Resource = var.cache_cluster_arn
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ecs_service" "services" {
|
||||||
|
for_each = var.services
|
||||||
|
|
||||||
|
name = "${var.cluster_name}-${each.key}"
|
||||||
|
cluster = aws_ecs_cluster.main.id
|
||||||
|
task_definition = aws_ecs_task_definition.services[each.key].arn
|
||||||
|
desired_count = var.environment == "production" ? 3 : 1
|
||||||
|
|
||||||
|
launch_type = "FARGATE"
|
||||||
|
|
||||||
|
network_configuration {
|
||||||
|
subnets = var.subnet_ids
|
||||||
|
security_groups = var.security_group_ids
|
||||||
|
assign_public_ip = false
|
||||||
|
}
|
||||||
|
|
||||||
|
load_balancer {
|
||||||
|
target_group_arn = aws_lb_target_group.services[each.key].arn
|
||||||
|
container_name = each.key
|
||||||
|
container_port = each.port
|
||||||
|
}
|
||||||
|
|
||||||
|
auto_scaling {
|
||||||
|
max_capacity = var.environment == "production" ? 10 : 3
|
||||||
|
min_capacity = var.environment == "production" ? 2 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.cluster_name}-${each.key}"
|
||||||
|
Service = each.key
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [
|
||||||
|
aws_lb_listener.https
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_lb" "main" {
|
||||||
|
name = "${var.cluster_name}-alb"
|
||||||
|
internal = false
|
||||||
|
load_balancer_type = "application"
|
||||||
|
security_groups = [var.alb_security_group_id]
|
||||||
|
subnets = var.public_subnet_ids
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.cluster_name}-alb"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_acm_certificate" "main" {
|
||||||
|
domain_name = "${var.cluster_name}.${var.environment}.shieldai.app"
|
||||||
|
validation_method = "DNS"
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.cluster_name}-cert"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_route53_zone" "main" {
|
||||||
|
name = var.domain_name
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_route53_record" "acm_validation" {
|
||||||
|
for_each = {
|
||||||
|
for rv in aws_acm_certificate.main.domain_validation_options : rv.domain_name => rv
|
||||||
|
if rv.resource_record_name != null
|
||||||
|
}
|
||||||
|
|
||||||
|
zone_id = data.aws_route53_zone.main.zone_id
|
||||||
|
name = each.value.resource_record_name
|
||||||
|
type = each.value.resource_record_type
|
||||||
|
ttl = 60
|
||||||
|
records = [each.value.resource_record_value]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_acm_certificate_validation" "main" {
|
||||||
|
certificate_arn = aws_acm_certificate.main.arn
|
||||||
|
validation_record_fqdns = [aws_route53_record.acm_validation[*].fqdn]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_lb_target_group" "services" {
|
||||||
|
for_each = var.services
|
||||||
|
|
||||||
|
name = "${var.cluster_name}-${each.key}-tg"
|
||||||
|
port = each.port
|
||||||
|
protocol = "HTTP"
|
||||||
|
vpc_id = var.vpc_id
|
||||||
|
|
||||||
|
health_check {
|
||||||
|
enabled = true
|
||||||
|
healthy_threshold = 3
|
||||||
|
interval = 30
|
||||||
|
matcher = "200"
|
||||||
|
path = "/health"
|
||||||
|
port = "traffic-port"
|
||||||
|
protocol = "HTTP"
|
||||||
|
timeout = 5
|
||||||
|
unhealthy_threshold = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
stickiness {
|
||||||
|
type = "lb_cookie"
|
||||||
|
cookie_duration = 86400
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_lb_listener" "https" {
|
||||||
|
load_balancer_arn = aws_lb.main.arn
|
||||||
|
port = 443
|
||||||
|
protocol = "HTTPS"
|
||||||
|
ssl_certificate_arn = aws_acm_certificate_validation.main.certificate_arn
|
||||||
|
|
||||||
|
default_action {
|
||||||
|
type = "forward"
|
||||||
|
target_group_arn = aws_lb_target_group.services["api"].arn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_lb_listener_rule" "services" {
|
||||||
|
for_each = { for k, v in var.services : k => v if k != "api" }
|
||||||
|
|
||||||
|
listener_arn = aws_lb_listener.https.arn
|
||||||
|
action {
|
||||||
|
type = "forward"
|
||||||
|
target_group_arn = aws_lb_target_group.services[each.key].arn
|
||||||
|
}
|
||||||
|
|
||||||
|
condition {
|
||||||
|
path_pattern {
|
||||||
|
values = ["/${each.key}/*", "/${each.key}"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_lb_listener" "http_redirect" {
|
||||||
|
load_balancer_arn = aws_lb.main.arn
|
||||||
|
port = 80
|
||||||
|
protocol = "HTTP"
|
||||||
|
|
||||||
|
default_action {
|
||||||
|
type = "redirect"
|
||||||
|
|
||||||
|
redirect {
|
||||||
|
port = "443"
|
||||||
|
protocol = "HTTPS"
|
||||||
|
status_code = "HTTP_301"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_appautoscaling_target" "services" {
|
||||||
|
for_each = var.services
|
||||||
|
|
||||||
|
service_namespace = "ecs"
|
||||||
|
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.services[each.key].name}"
|
||||||
|
scalable_dimension = "ecs:service:DesiredCount"
|
||||||
|
min_capacity = var.environment == "production" ? 2 : 1
|
||||||
|
max_capacity = var.environment == "production" ? 10 : 3
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_appautoscaling_policy" "cpu" {
|
||||||
|
for_each = var.services
|
||||||
|
|
||||||
|
name = "${var.cluster_name}-${each.key}-cpu-scaling"
|
||||||
|
service_namespace = "ecs"
|
||||||
|
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.services[each.key].name}"
|
||||||
|
scalable_dimension = "ecs:service:DesiredCount"
|
||||||
|
|
||||||
|
target_tracking_scaling_policy_configuration {
|
||||||
|
target_value = 70.0
|
||||||
|
scale_in_cooldown = 60
|
||||||
|
scale_out_cooldown = 30
|
||||||
|
|
||||||
|
customized_metric_specification {
|
||||||
|
metric_name = "CPUUtilization"
|
||||||
|
namespace = "AWS/ECS"
|
||||||
|
statistic = "Average"
|
||||||
|
dimensions = [{ name = "ClusterName", value = aws_ecs_cluster.main.name }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_kms_key" "logs" {
|
||||||
|
description = "${var.cluster_name} logs encryption key"
|
||||||
|
deletion_window_in_days = 7
|
||||||
|
enable_key_rotation = true
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.cluster_name}-logs-kms"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_log_group" "services" {
|
||||||
|
for_each = var.services
|
||||||
|
|
||||||
|
name = "/ecs/${var.cluster_name}-${each.key}"
|
||||||
|
retention_in_days = var.environment == "production" ? 30 : 7
|
||||||
|
kms_key_id = aws_kms_key.logs.arn
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.cluster_name}-${each.key}-logs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output "cluster_arn" {
|
||||||
|
description = "ECS cluster ARN"
|
||||||
|
value = aws_ecs_cluster.main.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
output "alb_dns_name" {
|
||||||
|
description = "ALB DNS name"
|
||||||
|
value = aws_lb.main.dns_name
|
||||||
|
}
|
||||||
|
|
||||||
|
output "kms_key_arn" {
|
||||||
|
description = "KMS key ARN for log encryption"
|
||||||
|
value = aws_kms_key.logs.arn
|
||||||
|
}
|
||||||
102
infra/modules/elasticache/main.tf
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
variable "environment" {
|
||||||
|
description = "Deployment environment"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vpc_id" {
|
||||||
|
description = "VPC ID"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "subnet_ids" {
|
||||||
|
description = "Private subnet IDs"
|
||||||
|
type = list(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "security_group_id" {
|
||||||
|
description = "ElastiCache security group ID"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "node_type" {
|
||||||
|
description = "Cache node type"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "num_nodes" {
|
||||||
|
description = "Number of cache nodes"
|
||||||
|
type = number
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "project_name" {
|
||||||
|
description = "Project name"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_elasticache_subnet_group" "main" {
|
||||||
|
name = "${var.project_name}-${var.environment}-redis-subnet"
|
||||||
|
subnet_ids = var.subnet_ids
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-${var.environment}-redis-subnet"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "random_password" "redis_auth" {
|
||||||
|
length = 32
|
||||||
|
special = false
|
||||||
|
|
||||||
|
keepers = {
|
||||||
|
environment = var.environment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_elasticache_replication_group" "main" {
|
||||||
|
replication_group_id = "${var.project_name}-${var.environment}-redis"
|
||||||
|
description = "${var.project_name} Redis cluster (${var.environment})"
|
||||||
|
|
||||||
|
node_type = var.node_type
|
||||||
|
num_cache_clusters = var.num_nodes
|
||||||
|
engine = "redis"
|
||||||
|
engine_version = "7.0"
|
||||||
|
|
||||||
|
auth_token = random_password.redis_auth.result
|
||||||
|
|
||||||
|
transit_encryption_enabled = true
|
||||||
|
at_rest_encryption_enabled = true
|
||||||
|
|
||||||
|
port = 6379
|
||||||
|
|
||||||
|
subnet_group_name = aws_elasticache_subnet_group.main.name
|
||||||
|
security_group_ids = [var.security_group_id]
|
||||||
|
|
||||||
|
automatic_failover_enabled = var.environment == "production"
|
||||||
|
|
||||||
|
snapshot_retention_limit = var.environment == "production" ? 7 : 1
|
||||||
|
snapshot_window = "03:00-04:00"
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-${var.environment}-redis"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output "cache_endpoint" {
|
||||||
|
description = "ElastiCache primary endpoint"
|
||||||
|
value = aws_elasticache_replication_group.main.primary_endpoint_address
|
||||||
|
}
|
||||||
|
|
||||||
|
output "reader_endpoint" {
|
||||||
|
description = "ElastiCache reader endpoint"
|
||||||
|
value = aws_elasticache_replication_group.main.reader_endpoint_address
|
||||||
|
}
|
||||||
|
|
||||||
|
output "auth_token" {
|
||||||
|
description = "Redis auth token"
|
||||||
|
value = random_password.redis_auth.result
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
output "replication_group_arn" {
|
||||||
|
description = "ElastiCache replication group ARN"
|
||||||
|
value = aws_elasticache_replication_group.main.arn
|
||||||
|
}
|
||||||
138
infra/modules/rds/main.tf
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
variable "environment" {
|
||||||
|
description = "Deployment environment"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vpc_id" {
|
||||||
|
description = "VPC ID"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "subnet_ids" {
|
||||||
|
description = "Private subnet IDs"
|
||||||
|
type = list(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "security_group_id" {
|
||||||
|
description = "RDS security group ID"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "db_name" {
|
||||||
|
description = "Database name"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "db_instance_class" {
|
||||||
|
description = "RDS instance class"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "multi_az" {
|
||||||
|
description = "Multi-AZ deployment"
|
||||||
|
type = bool
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "backup_retention" {
|
||||||
|
description = "Backup retention days"
|
||||||
|
type = number
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "project_name" {
|
||||||
|
description = "Project name"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_db_subnet_group" "main" {
|
||||||
|
name = "${var.project_name}-${var.environment}-db-subnet"
|
||||||
|
subnet_ids = var.subnet_ids
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-${var.environment}-db-subnet"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_db_instance" "main" {
|
||||||
|
identifier = "${var.project_name}-${var.environment}-db"
|
||||||
|
|
||||||
|
engine = "postgres"
|
||||||
|
engine_version = "16.2"
|
||||||
|
instance_class = var.db_instance_class
|
||||||
|
allocated_storage = var.environment == "production" ? 100 : 20
|
||||||
|
|
||||||
|
db_name = var.db_name
|
||||||
|
username = "shieldai"
|
||||||
|
password = random_password.db_password.result
|
||||||
|
|
||||||
|
multi_az = var.multi_az
|
||||||
|
db_subnet_group_name = aws_db_subnet_group.main.name
|
||||||
|
vpc_security_group_ids = [var.security_group_id]
|
||||||
|
|
||||||
|
backup_retention_period = var.backup_retention
|
||||||
|
backup_window = "03:00-04:00"
|
||||||
|
maintenance_window = "sun:04:00-sun:05:00"
|
||||||
|
|
||||||
|
skip_final_snapshot = var.environment != "production"
|
||||||
|
final_snapshot_identifier = "${var.project_name}-${var.environment}-final"
|
||||||
|
|
||||||
|
storage_encrypted = true
|
||||||
|
storage_type = "gp3"
|
||||||
|
iops = var.environment == "production" ? 3000 : 1000
|
||||||
|
|
||||||
|
deletion_protection = var.environment == "production"
|
||||||
|
copy_tags_to_snapshot = true
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-${var.environment}-db"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "random_password" "db_password" {
|
||||||
|
length = 16
|
||||||
|
special = true
|
||||||
|
|
||||||
|
keepers = {
|
||||||
|
environment = var.environment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_secretsmanager_secret_version" "db_password" {
|
||||||
|
secret_id = aws_secretsmanager_secret.db_password.id
|
||||||
|
secret_string = jsonencode({
|
||||||
|
username = "shieldai"
|
||||||
|
password = random_password.db_password.result
|
||||||
|
engine = "postgres"
|
||||||
|
host = aws_db_instance.main.address
|
||||||
|
port = aws_db_instance.main.port
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_secretsmanager_secret" "db_password" {
|
||||||
|
name = "${var.project_name}-${var.environment}-db-password"
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-${var.environment}-db-password"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output "db_endpoint" {
|
||||||
|
description = "RDS endpoint"
|
||||||
|
value = aws_db_instance.main.endpoint
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
output "db_instance_identifier" {
|
||||||
|
description = "RDS instance identifier"
|
||||||
|
value = aws_db_instance.main.identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
output "db_password_secret_arn" {
|
||||||
|
description = "DB password secret ARN"
|
||||||
|
value = aws_secretsmanager_secret.db_password.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
output "db_password" {
|
||||||
|
description = "Generated DB password"
|
||||||
|
value = random_password.db_password.result
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
145
infra/modules/s3/main.tf
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
variable "environment" {
|
||||||
|
description = "Deployment environment"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "project_name" {
|
||||||
|
description = "Project name"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket" "terraform_state" {
|
||||||
|
bucket = "${var.project_name}-${var.environment}-terraform-state"
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-${var.environment}-terraform-state"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_public_access_block" "terraform_state" {
|
||||||
|
bucket = aws_s3_bucket.terraform_state.id
|
||||||
|
|
||||||
|
block_public_acls = true
|
||||||
|
block_public_policy = true
|
||||||
|
ignore_public_acls = true
|
||||||
|
restrict_public_buckets = true
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_versioning" "terraform_state" {
|
||||||
|
bucket = aws_s3_bucket.terraform_state.id
|
||||||
|
versioning_configuration {
|
||||||
|
status = "Enabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
|
||||||
|
bucket = aws_s3_bucket.terraform_state.id
|
||||||
|
|
||||||
|
rule {
|
||||||
|
apply_server_side_encryption_by_default {
|
||||||
|
sse_algorithm = "aws:kms"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_lifecycle_configuration" "terraform_state" {
|
||||||
|
bucket = aws_s3_bucket.terraform_state.id
|
||||||
|
|
||||||
|
rule {
|
||||||
|
id = "expire-noncurrent"
|
||||||
|
status = "Enabled"
|
||||||
|
|
||||||
|
noncurrent_version_expiration {
|
||||||
|
noncurrent_days = 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket" "artifacts" {
|
||||||
|
bucket = "${var.project_name}-${var.environment}-artifacts"
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-${var.environment}-artifacts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_public_access_block" "artifacts" {
|
||||||
|
bucket = aws_s3_bucket.artifacts.id
|
||||||
|
|
||||||
|
block_public_acls = true
|
||||||
|
block_public_policy = true
|
||||||
|
ignore_public_acls = true
|
||||||
|
restrict_public_buckets = true
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_versioning" "artifacts" {
|
||||||
|
bucket = aws_s3_bucket.artifacts.id
|
||||||
|
versioning_configuration {
|
||||||
|
status = "Enabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" {
|
||||||
|
bucket = aws_s3_bucket.artifacts.id
|
||||||
|
|
||||||
|
rule {
|
||||||
|
apply_server_side_encryption_by_default {
|
||||||
|
sse_algorithm = "aws:kms"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket" "logs" {
|
||||||
|
bucket = "${var.project_name}-${var.environment}-logs"
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-${var.environment}-logs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_public_access_block" "logs" {
|
||||||
|
bucket = aws_s3_bucket.logs.id
|
||||||
|
|
||||||
|
block_public_acls = true
|
||||||
|
block_public_policy = true
|
||||||
|
ignore_public_acls = true
|
||||||
|
restrict_public_buckets = true
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_server_side_encryption_configuration" "logs" {
|
||||||
|
bucket = aws_s3_bucket.logs.id
|
||||||
|
|
||||||
|
rule {
|
||||||
|
apply_server_side_encryption_by_default {
|
||||||
|
sse_algorithm = "aws:kms"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_lifecycle_configuration" "logs" {
|
||||||
|
bucket = aws_s3_bucket.logs.id
|
||||||
|
|
||||||
|
rule {
|
||||||
|
id = "expire-old-logs"
|
||||||
|
status = "Enabled"
|
||||||
|
|
||||||
|
expiration {
|
||||||
|
days = 90
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output "bucket_name" {
|
||||||
|
description = "Terraform state S3 bucket name"
|
||||||
|
value = aws_s3_bucket.terraform_state.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "artifacts_bucket_name" {
|
||||||
|
description = "Artifacts S3 bucket name"
|
||||||
|
value = aws_s3_bucket.artifacts.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "logs_bucket_name" {
|
||||||
|
description = "Logs S3 bucket name"
|
||||||
|
value = aws_s3_bucket.logs.id
|
||||||
|
}
|
||||||
69
infra/modules/secrets/main.tf
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
variable "environment" {
|
||||||
|
description = "Deployment environment"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "project_name" {
|
||||||
|
description = "Project name"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "rds_endpoint" {
|
||||||
|
description = "RDS instance endpoint"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "db_password" {
|
||||||
|
description = "Generated RDS password"
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "elasticache_endpoint" {
|
||||||
|
description = "ElastiCache primary endpoint"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "redis_auth_token" {
|
||||||
|
description = "ElastiCache auth token"
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "secrets" {
|
||||||
|
description = "Secrets to store"
|
||||||
|
type = map(string)
|
||||||
|
default = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_secretsmanager_secret" "main" {
|
||||||
|
name = "${var.project_name}-${var.environment}-app-secrets"
|
||||||
|
|
||||||
|
description = "Application secrets for ${var.project_name} (${var.environment})"
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-${var.environment}-app-secrets"
|
||||||
|
Environment = var.environment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_secretsmanager_secret_version" "main" {
|
||||||
|
secret_id = aws_secretsmanager_secret.main.id
|
||||||
|
|
||||||
|
secret_string = jsonencode(merge({
|
||||||
|
DATABASE_URL = "postgresql://shieldai:${var.db_password}@${var.rds_endpoint}:5432/shieldai"
|
||||||
|
REDIS_URL = "redis://:${var.redis_auth_token}@${var.elasticache_endpoint}:6379"
|
||||||
|
NODE_ENV = var.environment
|
||||||
|
LOG_LEVEL = var.environment == "production" ? "info" : "debug"
|
||||||
|
}, var.secrets))
|
||||||
|
}
|
||||||
|
|
||||||
|
output "secrets_manager_arn" {
|
||||||
|
description = "Secrets Manager ARN"
|
||||||
|
value = aws_secretsmanager_secret.main.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
output "secrets_manager_name" {
|
||||||
|
description = "Secrets Manager secret name"
|
||||||
|
value = aws_secretsmanager_secret.main.name
|
||||||
|
}
|
||||||
338
infra/modules/vpc/main.tf
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
variable "environment" {
|
||||||
|
description = "Deployment environment"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vpc_cidr" {
|
||||||
|
description = "CIDR block for VPC"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "az_count" {
|
||||||
|
description = "Number of availability zones"
|
||||||
|
type = number
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "project_name" {
|
||||||
|
description = "Project name"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "kms_key_arn" {
|
||||||
|
description = "KMS key ARN for log encryption"
|
||||||
|
type = string
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_vpc" "main" {
|
||||||
|
cidr_block = var.vpc_cidr
|
||||||
|
enable_dns_support = true
|
||||||
|
enable_dns_hostnames = true
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-${var.environment}-vpc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_availability_zones" "available" {
|
||||||
|
state = "available"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_subnet" "public" {
|
||||||
|
count = var.az_count
|
||||||
|
|
||||||
|
vpc_id = aws_vpc.main.id
|
||||||
|
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
|
||||||
|
availability_zone = data.aws_availability_zones.available.names[count.index]
|
||||||
|
map_public_ip_on_launch = false
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-${var.environment}-public-${data.aws_availability_zones.available.names[count.index]}"
|
||||||
|
"kubernetes.io/role/elb" = "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_subnet" "private" {
|
||||||
|
count = var.az_count
|
||||||
|
|
||||||
|
vpc_id = aws_vpc.main.id
|
||||||
|
cidr_block = cidrsubnet(var.vpc_cidr, 8, var.az_count + count.index)
|
||||||
|
availability_zone = data.aws_availability_zones.available.names[count.index]
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-${var.environment}-private-${data.aws_availability_zones.available.names[count.index]}"
|
||||||
|
"kubernetes.io/role/internal-elb" = "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_internet_gateway" "main" {
|
||||||
|
vpc_id = aws_vpc.main.id
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-${var.environment}-igw"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_eip" "nat" {
|
||||||
|
count = var.az_count
|
||||||
|
|
||||||
|
domain = "vpc"
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-${var.environment}-nat-${count.index}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_nat_gateway" "main" {
|
||||||
|
count = var.az_count
|
||||||
|
|
||||||
|
allocation_id = aws_eip.nat[count.index].id
|
||||||
|
subnet_id = aws_subnet.public[count.index].id
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-${var.environment}-nat-${count.index}"
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [aws_internet_gateway.main]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_route_table" "public" {
|
||||||
|
vpc_id = aws_vpc.main.id
|
||||||
|
|
||||||
|
route {
|
||||||
|
cidr_block = "0.0.0.0/0"
|
||||||
|
gateway_id = aws_internet_gateway.main.id
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-${var.environment}-public-rt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_route_table" "private" {
|
||||||
|
count = var.az_count
|
||||||
|
|
||||||
|
vpc_id = aws_vpc.main.id
|
||||||
|
|
||||||
|
route {
|
||||||
|
cidr_block = "0.0.0.0/0"
|
||||||
|
nat_gateway_id = aws_nat_gateway.main[count.index].id
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-${var.environment}-private-rt-${count.index}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_route_table_association" "public" {
|
||||||
|
count = var.az_count
|
||||||
|
|
||||||
|
subnet_id = aws_subnet.public[count.index].id
|
||||||
|
route_table_id = aws_route_table.public.id
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_route_table_association" "private" {
|
||||||
|
count = var.az_count
|
||||||
|
|
||||||
|
subnet_id = aws_subnet.private[count.index].id
|
||||||
|
route_table_id = aws_route_table.private[count.index].id
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_security_group" "alb" {
|
||||||
|
name_prefix = "${var.project_name}-${var.environment}-alb"
|
||||||
|
vpc_id = aws_vpc.main.id
|
||||||
|
|
||||||
|
ingress {
|
||||||
|
from_port = 443
|
||||||
|
to_port = 443
|
||||||
|
protocol = "tcp"
|
||||||
|
cidr_blocks = ["0.0.0.0/0"]
|
||||||
|
description = "HTTPS from internet"
|
||||||
|
}
|
||||||
|
|
||||||
|
ingress {
|
||||||
|
from_port = 80
|
||||||
|
to_port = 80
|
||||||
|
protocol = "tcp"
|
||||||
|
cidr_blocks = ["0.0.0.0/0"]
|
||||||
|
description = "HTTP from internet (redirect)"
|
||||||
|
}
|
||||||
|
|
||||||
|
egress {
|
||||||
|
from_port = 0
|
||||||
|
to_port = 0
|
||||||
|
protocol = "-1"
|
||||||
|
cidr_blocks = ["0.0.0.0/0"]
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-${var.environment}-alb-sg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_security_group" "ecs" {
|
||||||
|
name_prefix = "${var.project_name}-${var.environment}-ecs"
|
||||||
|
vpc_id = aws_vpc.main.id
|
||||||
|
|
||||||
|
ingress {
|
||||||
|
from_port = 3000
|
||||||
|
to_port = 3003
|
||||||
|
protocol = "tcp"
|
||||||
|
security_groups = [aws_security_group.alb.id]
|
||||||
|
description = "Service ports from ALB only"
|
||||||
|
}
|
||||||
|
|
||||||
|
egress {
|
||||||
|
from_port = 0
|
||||||
|
to_port = 0
|
||||||
|
protocol = "-1"
|
||||||
|
cidr_blocks = ["0.0.0.0/0"]
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-${var.environment}-ecs-sg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_security_group" "rds" {
|
||||||
|
name_prefix = "${var.project_name}-${var.environment}-rds"
|
||||||
|
vpc_id = aws_vpc.main.id
|
||||||
|
|
||||||
|
ingress {
|
||||||
|
from_port = 5432
|
||||||
|
to_port = 5432
|
||||||
|
protocol = "tcp"
|
||||||
|
security_groups = [aws_security_group.ecs.id]
|
||||||
|
description = "PostgreSQL from ECS"
|
||||||
|
}
|
||||||
|
|
||||||
|
egress {
|
||||||
|
from_port = 0
|
||||||
|
to_port = 0
|
||||||
|
protocol = "-1"
|
||||||
|
cidr_blocks = ["0.0.0.0/0"]
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-${var.environment}-rds-sg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_security_group" "elasticache" {
|
||||||
|
name_prefix = "${var.project_name}-${var.environment}-elasticache"
|
||||||
|
vpc_id = aws_vpc.main.id
|
||||||
|
|
||||||
|
ingress {
|
||||||
|
from_port = 6379
|
||||||
|
to_port = 6379
|
||||||
|
protocol = "tcp"
|
||||||
|
security_groups = [aws_security_group.ecs.id]
|
||||||
|
description = "Redis from ECS"
|
||||||
|
}
|
||||||
|
|
||||||
|
egress {
|
||||||
|
from_port = 0
|
||||||
|
to_port = 0
|
||||||
|
protocol = "-1"
|
||||||
|
cidr_blocks = ["0.0.0.0/0"]
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-${var.environment}-elasticache-sg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_flow_log" "main" {
|
||||||
|
iam_role_arn = aws_iam_role.flow_log.arn
|
||||||
|
log_destination = aws_cloudwatch_log_group.flow_log.arn
|
||||||
|
vpc_id = aws_vpc.main.id
|
||||||
|
traffic_type = "ALL"
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-${var.environment}-flow-log"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role" "flow_log" {
|
||||||
|
name = "${var.project_name}-${var.environment}-flow-log-role"
|
||||||
|
|
||||||
|
assume_role_policy = jsonencode({
|
||||||
|
Version = "2012-10-17"
|
||||||
|
Statement = [
|
||||||
|
{
|
||||||
|
Action = "sts:AssumeRole"
|
||||||
|
Effect = "Allow"
|
||||||
|
Principal = {
|
||||||
|
Service = "vpc-flow-logs.amazonaws.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy" "flow_log" {
|
||||||
|
name = "${var.project_name}-${var.environment}-flow-log-policy"
|
||||||
|
role = aws_iam_role.flow_log.id
|
||||||
|
|
||||||
|
policy = jsonencode({
|
||||||
|
Version = "2012-10-17"
|
||||||
|
Statement = [
|
||||||
|
{
|
||||||
|
Action = [
|
||||||
|
"logs:CreateLogGroup",
|
||||||
|
"logs:CreateLogStream",
|
||||||
|
"logs:PutLogEvents",
|
||||||
|
"logs:DescribeLogGroups",
|
||||||
|
"logs:DescribeLogStreams"
|
||||||
|
]
|
||||||
|
Effect = "Allow"
|
||||||
|
Resource = [aws_cloudwatch_log_group.flow_log.arn]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_log_group" "flow_log" {
|
||||||
|
name = "/${var.project_name}/${var.environment}/vpc-flow-log"
|
||||||
|
retention_in_days = var.environment == "production" ? 30 : 7
|
||||||
|
kms_key_id = var.kms_key_arn != "" ? var.kms_key_arn : null
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-${var.environment}-flow-log"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output "vpc_id" {
|
||||||
|
description = "VPC ID"
|
||||||
|
value = aws_vpc.main.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "private_subnet_ids" {
|
||||||
|
description = "Private subnet IDs"
|
||||||
|
value = aws_subnet.private[*].id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "public_subnet_ids" {
|
||||||
|
description = "Public subnet IDs"
|
||||||
|
value = aws_subnet.public[*].id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "alb_security_group_id" {
|
||||||
|
description = "ALB security group ID"
|
||||||
|
value = aws_security_group.alb.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "ecs_security_group_id" {
|
||||||
|
description = "ECS security group ID"
|
||||||
|
value = aws_security_group.ecs.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "rds_security_group_id" {
|
||||||
|
description = "RDS security group ID"
|
||||||
|
value = aws_security_group.rds.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "elasticache_security_group_id" {
|
||||||
|
description = "ElastiCache security group ID"
|
||||||
|
value = aws_security_group.elasticache.id
|
||||||
|
}
|
||||||
35
infra/outputs.tf
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
output "vpc_id" {
|
||||||
|
description = "VPC ID"
|
||||||
|
value = module.vpc.vpc_id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "cluster_name" {
|
||||||
|
description = "ECS cluster name"
|
||||||
|
value = "${var.project_name}-${var.environment}"
|
||||||
|
}
|
||||||
|
|
||||||
|
output "rds_endpoint" {
|
||||||
|
description = "RDS endpoint"
|
||||||
|
value = module.rds.db_endpoint
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
output "elasticache_endpoint" {
|
||||||
|
description = "ElastiCache primary endpoint"
|
||||||
|
value = module.elasticache.cache_endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
output "s3_bucket_name" {
|
||||||
|
description = "S3 bucket name"
|
||||||
|
value = module.s3.bucket_name
|
||||||
|
}
|
||||||
|
|
||||||
|
output "secrets_manager_arn" {
|
||||||
|
description = "Secrets Manager ARN"
|
||||||
|
value = module.secrets.secrets_manager_arn
|
||||||
|
}
|
||||||
|
|
||||||
|
output "cloudwatch_dashboard_url" {
|
||||||
|
description = "CloudWatch dashboard URL"
|
||||||
|
value = module.cloudwatch.dashboard_url
|
||||||
|
}
|
||||||
121
infra/scripts/rollback-compose.sh
Executable file
@@ -0,0 +1,121 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ShieldAI Docker Compose Rollback Script
|
||||||
|
# Usage: ./rollback-compose.sh <previous_tag> [--env prod|dev]
|
||||||
|
#
|
||||||
|
# Rolls back all services to a previous tagged image using docker-compose.prod.yml
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# ./rollback-compose.sh v1.2.3 # Rollback to v1.2.3
|
||||||
|
# ./rollback-compose.sh v1.2.3 --env prod # Explicit production compose
|
||||||
|
|
||||||
|
PREVIOUS_TAG="${1:-}"
|
||||||
|
ENV_MODE="${2:-prod}"
|
||||||
|
|
||||||
|
# ─── Configuration ───────────────────────────────────────────────
|
||||||
|
SERVICES="api darkwatch spamshield voiceprint"
|
||||||
|
COMPOSE_FILE="docker-compose.prod.yml"
|
||||||
|
REGISTRY_OWNER="${GITHUB_REPOSITORY_OWNER:-shieldai}"
|
||||||
|
|
||||||
|
# ─── Helpers ─────────────────────────────────────────────────────
|
||||||
|
log() {
|
||||||
|
local level="$1"
|
||||||
|
shift
|
||||||
|
echo "[$(date -u '+%H:%M:%S')] [$level] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_info() { log "INFO" "$@"; }
|
||||||
|
log_warn() { log "WARN" "$@"; }
|
||||||
|
log_error() { log "ERROR" "$@"; }
|
||||||
|
|
||||||
|
# ─── Validation ──────────────────────────────────────────────────
|
||||||
|
if [[ -z "$PREVIOUS_TAG" ]]; then
|
||||||
|
log_error "Usage: $0 <previous_tag> [--env prod|dev]"
|
||||||
|
log_error "Example: $0 v1.2.3"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v docker &>/dev/null; then
|
||||||
|
log_error "Docker not found in PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Rollback Logic ──────────────────────────────────────────────
|
||||||
|
main() {
|
||||||
|
log_info "=== Docker Compose Rollback ==="
|
||||||
|
log_info "Target tag: $PREVIOUS_TAG"
|
||||||
|
log_info "Compose file: $COMPOSE_FILE"
|
||||||
|
log_info "Registry: ghcr.io/$REGISTRY_OWNER"
|
||||||
|
|
||||||
|
# 1. Pull previous images
|
||||||
|
log_info "Pulling previous images..."
|
||||||
|
local pull_failed=0
|
||||||
|
for svc in $SERVICES; do
|
||||||
|
local image="ghcr.io/${REGISTRY_OWNER}/shieldai-${svc}:${PREVIOUS_TAG}"
|
||||||
|
log_info "Pulling $image..."
|
||||||
|
if docker pull "$image" 2>/dev/null; then
|
||||||
|
log_info "Pulled: $image"
|
||||||
|
else
|
||||||
|
log_warn "Pull failed: $image (may not exist)"
|
||||||
|
pull_failed=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ $pull_failed -eq 1 ]]; then
|
||||||
|
log_warn "Some images may not exist at tag $PREVIOUS_TAG"
|
||||||
|
log_info "Continuing with available images..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Stop current services gracefully
|
||||||
|
log_info "Stopping current services..."
|
||||||
|
DOCKER_TAG="$PREVIOUS_TAG" docker compose -f "$COMPOSE_FILE" down --timeout 30 2>/dev/null || true
|
||||||
|
|
||||||
|
# 3. Start with previous tag
|
||||||
|
log_info "Starting services with tag $PREVIOUS_TAG..."
|
||||||
|
DOCKER_TAG="$PREVIOUS_TAG" docker compose -f "$COMPOSE_FILE" up -d
|
||||||
|
|
||||||
|
# 4. Wait for services to be healthy
|
||||||
|
log_info "Waiting for services to become healthy..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# 5. Verify health
|
||||||
|
local passed=0
|
||||||
|
local failed=0
|
||||||
|
|
||||||
|
for svc in $SERVICES; do
|
||||||
|
local port
|
||||||
|
port=$(case "$svc" in
|
||||||
|
api) echo 3000 ;;
|
||||||
|
darkwatch) echo 3001 ;;
|
||||||
|
spamshield) echo 3002 ;;
|
||||||
|
voiceprint) echo 3003 ;;
|
||||||
|
esac)
|
||||||
|
|
||||||
|
local http_code
|
||||||
|
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
--connect-timeout 10 --max-time 30 \
|
||||||
|
"http://localhost:${port}/health" 2>/dev/null || echo "000")
|
||||||
|
|
||||||
|
if [[ "$http_code" == "200" ]]; then
|
||||||
|
log_info "Health OK: $svc (port $port, HTTP $http_code)"
|
||||||
|
((passed++))
|
||||||
|
else
|
||||||
|
log_warn "Health FAIL: $svc (port $port, HTTP $http_code)"
|
||||||
|
((failed++))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
log_info "=== Rollback Complete ==="
|
||||||
|
log_info "Passed: $passed, Failed: $failed"
|
||||||
|
|
||||||
|
if [[ $failed -gt 0 ]]; then
|
||||||
|
log_warn "Some services failed health check. Check logs: docker compose -f $COMPOSE_FILE logs"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "All services healthy after rollback"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
164
infra/scripts/rollback-migration.sh
Executable file
@@ -0,0 +1,164 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ShieldAI Database Migration Rollback Script
|
||||||
|
# Usage: ./rollback-migration.sh <environment> [--migration <name>]
|
||||||
|
#
|
||||||
|
# Rolls back the most recent migration or a specific named migration
|
||||||
|
# Uses AWS Secrets Manager for database credentials
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# ./rollback-migration.sh staging # Rollback latest
|
||||||
|
# ./rollback-migration.sh production --migration 001_create_users # Rollback specific
|
||||||
|
|
||||||
|
ENVIRONMENT="${1:-staging}"
|
||||||
|
MIGRATION_NAME="${3:-}"
|
||||||
|
|
||||||
|
# ─── Configuration ───────────────────────────────────────────────
|
||||||
|
SECRET_ID="shieldai-${ENVIRONMENT}-db-password"
|
||||||
|
DB_NAME="shieldai"
|
||||||
|
DB_USER="shieldai"
|
||||||
|
|
||||||
|
# ─── Helpers ─────────────────────────────────────────────────────
|
||||||
|
log() {
|
||||||
|
local level="$1"
|
||||||
|
shift
|
||||||
|
echo "[$(date -u '+%H:%M:%S')] [$level] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_info() { log "INFO" "$@"; }
|
||||||
|
log_warn() { log "WARN" "$@"; }
|
||||||
|
log_error() { log "ERROR" "$@"; }
|
||||||
|
|
||||||
|
# ─── Validation ──────────────────────────────────────────────────
|
||||||
|
if [[ "$ENVIRONMENT" != "staging" && "$ENVIRONMENT" != "production" ]]; then
|
||||||
|
log_error "Invalid environment: $ENVIRONMENT (expected: staging, production)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for cmd in aws jq; do
|
||||||
|
if ! command -v "$cmd" &>/dev/null; then
|
||||||
|
log_error "Missing prerequisite: $cmd"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ─── Credentials ─────────────────────────────────────────────────
|
||||||
|
get_db_credentials() {
|
||||||
|
log_info "Fetching database credentials from Secrets Manager..."
|
||||||
|
|
||||||
|
local secret
|
||||||
|
secret=$(aws secretsmanager get-secret-value \
|
||||||
|
--secret-id "$SECRET_ID" \
|
||||||
|
--query 'SecretString' \
|
||||||
|
--output json 2>/dev/null)
|
||||||
|
|
||||||
|
if [[ -z "$secret" ]]; then
|
||||||
|
log_error "Failed to fetch secret: $SECRET_ID"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export DB_HOST=$(echo "$secret" | jq -r '.host')
|
||||||
|
export DB_PORT=$(echo "$secret" | jq -r '.port' // '5432')
|
||||||
|
export DB_PASS=$(echo "$secret" | jq -r '.password')
|
||||||
|
export DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}"
|
||||||
|
|
||||||
|
log_info "Database: ${DB_HOST}:${DB_PORT}/${DB_NAME}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Migration Status ────────────────────────────────────────────
|
||||||
|
show_migration_status() {
|
||||||
|
log_info "=== Current Migration Status ==="
|
||||||
|
|
||||||
|
if command -v npx &>/dev/null; then
|
||||||
|
npx drizzle-kit status --config=drizzle.config.ts 2>/dev/null || \
|
||||||
|
log_warn "Drizzle status check completed (some warnings expected)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Show applied migrations from database
|
||||||
|
log_info "Applied migrations:"
|
||||||
|
PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" \
|
||||||
|
-c "SELECT id, checksum, type FROM __drizzle_migrations_schema ORDER BY id DESC;" 2>/dev/null || \
|
||||||
|
log_warn "Could not query migration table (psql may not be installed)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Rollback Logic ──────────────────────────────────────────────
|
||||||
|
rollback_latest() {
|
||||||
|
log_info "=== Rolling Back Latest Migration ==="
|
||||||
|
|
||||||
|
# Get the latest applied migration
|
||||||
|
local latest_migration
|
||||||
|
latest_migration=$(PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" \
|
||||||
|
-U "$DB_USER" -d "$DB_NAME" -t -A \
|
||||||
|
-c "SELECT id FROM __drizzle_migrations_schema ORDER BY id DESC LIMIT 1;" 2>/dev/null)
|
||||||
|
|
||||||
|
if [[ -z "$latest_migration" ]]; then
|
||||||
|
log_warn "No applied migrations found"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Latest migration: $latest_migration"
|
||||||
|
|
||||||
|
# Resolve the migration (marks it as not applied)
|
||||||
|
if command -v npx &>/dev/null; then
|
||||||
|
npx drizzle-kit migrate:resolve --migration "$latest_migration" --status applied 2>/dev/null || \
|
||||||
|
log_warn "Migration resolve completed (check output for details)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Migration $latest_migration marked as resolved"
|
||||||
|
}
|
||||||
|
|
||||||
|
rollback_specific() {
|
||||||
|
local target="$1"
|
||||||
|
log_info "=== Rolling Back Migration: $target ==="
|
||||||
|
|
||||||
|
if command -v npx &>/dev/null; then
|
||||||
|
npx drizzle-kit migrate:resolve --migration "$target" --status applied 2>/dev/null || \
|
||||||
|
log_warn "Migration resolve completed (check output for details)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Migration $target marked as resolved"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Verification ────────────────────────────────────────────────
|
||||||
|
verify_connection() {
|
||||||
|
log_info "=== Verifying Database Connection ==="
|
||||||
|
|
||||||
|
local result
|
||||||
|
result=$(PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" \
|
||||||
|
-U "$DB_USER" -d "$DB_NAME" -t -A \
|
||||||
|
-c "SELECT version();" 2>/dev/null || echo "FAIL")
|
||||||
|
|
||||||
|
if [[ "$result" != "FAIL" ]]; then
|
||||||
|
log_info "Connection OK: PostgreSQL $result"
|
||||||
|
else
|
||||||
|
log_warn "Connection check failed"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Main ────────────────────────────────────────────────────────
|
||||||
|
main() {
|
||||||
|
log_info "=== ShieldAI Migration Rollback ==="
|
||||||
|
log_info "Environment: $ENVIRONMENT"
|
||||||
|
log_info "Secret: $SECRET_ID"
|
||||||
|
|
||||||
|
get_db_credentials
|
||||||
|
show_migration_status
|
||||||
|
|
||||||
|
if [[ -n "$MIGRATION_NAME" ]]; then
|
||||||
|
rollback_specific "$MIGRATION_NAME"
|
||||||
|
else
|
||||||
|
rollback_latest
|
||||||
|
fi
|
||||||
|
|
||||||
|
verify_connection
|
||||||
|
show_migration_status
|
||||||
|
|
||||||
|
log_info "=== Rollback Complete ==="
|
||||||
|
log_info "Next steps:"
|
||||||
|
log_info "1. Verify application schema compatibility"
|
||||||
|
log_info "2. Run application health checks"
|
||||||
|
log_info "3. If needed, redeploy ECS services: ./rollback.sh $ENVIRONMENT all"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
255
infra/scripts/rollback.sh
Executable file
@@ -0,0 +1,255 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ShieldAI ECS Rollback Script
|
||||||
|
# Usage: ./rollback.sh <environment> <service|all> [--verify]
|
||||||
|
#
|
||||||
|
# Environments: staging, production
|
||||||
|
# Services: api, darkwatch, spamshield, voiceprint, all
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# ./rollback.sh staging api # Rollback single service
|
||||||
|
# ./rollback.sh production all # Rollback all services
|
||||||
|
# ./rollback.sh production all --verify # Rollback with post-verification
|
||||||
|
|
||||||
|
# ─── Configuration ───────────────────────────────────────────────
|
||||||
|
ENVIRONMENT="${1:-staging}"
|
||||||
|
SERVICE="${2:-all}"
|
||||||
|
VERIFY="${3:-false}"
|
||||||
|
|
||||||
|
CLUSTER="shieldai-${ENVIRONMENT}"
|
||||||
|
SERVICES_LIST="api darkwatch spamshield voiceprint"
|
||||||
|
EXIT_CODE=0
|
||||||
|
TIMESTAMP=$(date -u '+%Y-%m-%d %H:%M:%S UTC')
|
||||||
|
LOG_FILE="/tmp/shieldai-rollback-${ENVIRONMENT}-${TIMESTAMP//[: ]/_}.log"
|
||||||
|
|
||||||
|
# ─── Helpers ─────────────────────────────────────────────────────
|
||||||
|
log() {
|
||||||
|
local level="$1"
|
||||||
|
shift
|
||||||
|
local msg="$*"
|
||||||
|
echo "[$(date -u '+%H:%M:%S')] [$level] $msg" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_info() { log "INFO" "$@"; }
|
||||||
|
log_warn() { log "WARN" "$@"; }
|
||||||
|
log_error() { log "ERROR" "$@"; }
|
||||||
|
|
||||||
|
# ─── Validation ──────────────────────────────────────────────────
|
||||||
|
validate_environment() {
|
||||||
|
if [[ "$ENVIRONMENT" != "staging" && "$ENVIRONMENT" != "production" ]]; then
|
||||||
|
log_error "Invalid environment: $ENVIRONMENT (expected: staging, production)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_service() {
|
||||||
|
if [[ "$SERVICE" == "all" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if ! echo "$SERVICES_LIST" | grep -qw "$SERVICE"; then
|
||||||
|
log_error "Invalid service: $SERVICE (expected: api, darkwatch, spamshield, voiceprint, all)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_prerequisites() {
|
||||||
|
local missing=()
|
||||||
|
|
||||||
|
for cmd in aws jq curl; do
|
||||||
|
if ! command -v "$cmd" &>/dev/null; then
|
||||||
|
missing+=("$cmd")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ${#missing[@]} -gt 0 ]]; then
|
||||||
|
log_error "Missing prerequisites: ${missing[*]}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${AWS_DEFAULT_REGION:-}" ]]; then
|
||||||
|
export AWS_DEFAULT_REGION="us-east-1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Prerequisites OK (region: $AWS_DEFAULT_REGION)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Rollback Logic ──────────────────────────────────────────────
|
||||||
|
get_target_services() {
|
||||||
|
if [[ "$SERVICE" == "all" ]]; then
|
||||||
|
echo "$SERVICES_LIST"
|
||||||
|
else
|
||||||
|
echo "$SERVICE"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
rollback_service() {
|
||||||
|
local svc="$1"
|
||||||
|
local service_name="${CLUSTER}-${svc}"
|
||||||
|
|
||||||
|
log_info "Rolling back $service_name..."
|
||||||
|
|
||||||
|
# Check current deployment status
|
||||||
|
local current_task_def
|
||||||
|
current_task_def=$(aws ecs describe-services \
|
||||||
|
--cluster "$CLUSTER" \
|
||||||
|
--services "$service_name" \
|
||||||
|
--query 'services[0].taskDefinition' \
|
||||||
|
--output text 2>/dev/null || echo "UNKNOWN")
|
||||||
|
|
||||||
|
log_info "Current task definition: $current_task_def"
|
||||||
|
|
||||||
|
# Execute rollback
|
||||||
|
if aws ecs update-service \
|
||||||
|
--cluster "$CLUSTER" \
|
||||||
|
--service "$service_name" \
|
||||||
|
--rollback \
|
||||||
|
--no-cli-auto-prompt 2>>"$LOG_FILE"; then
|
||||||
|
log_info "Rollback initiated for $service_name"
|
||||||
|
else
|
||||||
|
log_error "Rollback failed to initiate for $service_name"
|
||||||
|
EXIT_CODE=1
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wait for stabilization (max 5 minutes)
|
||||||
|
log_info "Waiting for $service_name to stabilize (timeout: 300s)..."
|
||||||
|
if aws ecs wait services-stable \
|
||||||
|
--cluster "$CLUSTER" \
|
||||||
|
--services "$service_name" \
|
||||||
|
--timeout 300 2>>"$LOG_FILE"; then
|
||||||
|
log_info "$service_name stabilized successfully"
|
||||||
|
else
|
||||||
|
log_warn "$service_name stabilization timed out or failed"
|
||||||
|
EXIT_CODE=1
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get new task definition after rollback
|
||||||
|
local new_task_def
|
||||||
|
new_task_def=$(aws ecs describe-services \
|
||||||
|
--cluster "$CLUSTER" \
|
||||||
|
--services "$service_name" \
|
||||||
|
--query 'services[0].taskDefinition' \
|
||||||
|
--output text 2>/dev/null || echo "UNKNOWN")
|
||||||
|
|
||||||
|
local running_count
|
||||||
|
running_count=$(aws ecs describe-services \
|
||||||
|
--cluster "$CLUSTER" \
|
||||||
|
--services "$service_name" \
|
||||||
|
--query 'services[0].runningCount' \
|
||||||
|
--output text 2>/dev/null || echo "0")
|
||||||
|
|
||||||
|
local desired_count
|
||||||
|
desired_count=$(aws ecs describe-services \
|
||||||
|
--cluster "$CLUSTER" \
|
||||||
|
--services "$service_name" \
|
||||||
|
--query 'services[0].desiredCount' \
|
||||||
|
--output text 2>/dev/null || echo "0")
|
||||||
|
|
||||||
|
log_info "Rollback complete: $service_name -> $new_task_def ($running_count/$desired_count running)"
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Health Verification ─────────────────────────────────────────
|
||||||
|
verify_health() {
|
||||||
|
local svc="$1"
|
||||||
|
local port
|
||||||
|
port=$(case "$svc" in
|
||||||
|
api) echo 3000 ;;
|
||||||
|
darkwatch) echo 3001 ;;
|
||||||
|
spamshield) echo 3002 ;;
|
||||||
|
voiceprint) echo 3003 ;;
|
||||||
|
*) echo 3000 ;;
|
||||||
|
esac)
|
||||||
|
|
||||||
|
local alb_dns="https://${CLUSTER}-alb.${AWS_DEFAULT_REGION}.elb.amazonaws.com"
|
||||||
|
|
||||||
|
log_info "Verifying health for $svc (ALB: $alb_dns)..."
|
||||||
|
|
||||||
|
local http_code
|
||||||
|
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
--connect-timeout 10 \
|
||||||
|
--max-time 30 \
|
||||||
|
"$alb_dns/health" 2>/dev/null || echo "000")
|
||||||
|
|
||||||
|
if [[ "$http_code" == "200" ]]; then
|
||||||
|
log_info "Health check PASSED: $svc (HTTP $http_code)"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log_warn "Health check FAILED: $svc (HTTP $http_code)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_all_services() {
|
||||||
|
log_info "=== Post-Rollback Health Verification ==="
|
||||||
|
local passed=0
|
||||||
|
local failed=0
|
||||||
|
|
||||||
|
for svc in $(get_target_services); do
|
||||||
|
if verify_health "$svc"; then
|
||||||
|
((passed++))
|
||||||
|
else
|
||||||
|
((failed++))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
log_info "Verification complete: $passed passed, $failed failed"
|
||||||
|
|
||||||
|
if [[ $failed -gt 0 ]]; then
|
||||||
|
log_warn "Some services failed health verification"
|
||||||
|
EXIT_CODE=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Main Execution ──────────────────────────────────────────────
|
||||||
|
main() {
|
||||||
|
log_info "=== ShieldAI Rollback ==="
|
||||||
|
log_info "Environment: $ENVIRONMENT"
|
||||||
|
log_info "Service(s): $SERVICE"
|
||||||
|
log_info "Cluster: $CLUSTER"
|
||||||
|
log_info "Verify: $VERIFY"
|
||||||
|
log_info "Timestamp: $TIMESTAMP"
|
||||||
|
log_info "Log file: $LOG_FILE"
|
||||||
|
log_info "=========================="
|
||||||
|
|
||||||
|
# Validate inputs
|
||||||
|
validate_environment
|
||||||
|
validate_service
|
||||||
|
check_prerequisites
|
||||||
|
|
||||||
|
# Execute rollback for each target service
|
||||||
|
local rolled_back=0
|
||||||
|
local failed=0
|
||||||
|
|
||||||
|
for svc in $(get_target_services); do
|
||||||
|
if rollback_service "$svc"; then
|
||||||
|
((rolled_back++))
|
||||||
|
else
|
||||||
|
((failed++))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
log_info "=== Rollback Summary ==="
|
||||||
|
log_info "Rolled back: $rolled_back services"
|
||||||
|
log_info "Failed: $failed services"
|
||||||
|
|
||||||
|
# Post-rollback verification
|
||||||
|
if [[ "$VERIFY" == "--verify" ]] || [[ "$VERIFY" == "true" ]]; then
|
||||||
|
verify_all_services
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $failed -gt 0 ]]; then
|
||||||
|
log_error "Rollback completed with $failed failure(s)"
|
||||||
|
log_info "Full log: $LOG_FILE"
|
||||||
|
exit "$EXIT_CODE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Rollback completed successfully"
|
||||||
|
log_info "Full log: $LOG_FILE"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
237
infra/scripts/test-rollback.sh
Executable file
@@ -0,0 +1,237 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
# ShieldAI Rollback Test Suite
|
||||||
|
# Usage: ./test-rollback.sh [ecs|compose|migration|all]
|
||||||
|
#
|
||||||
|
# Validates rollback scripts and procedures without mutating production
|
||||||
|
# Run against staging environment for integration tests
|
||||||
|
|
||||||
|
TEST_SUITE="${1:-all}"
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
SKIP=0
|
||||||
|
|
||||||
|
# ─── Helpers ─────────────────────────────────────────────────────
|
||||||
|
log() {
|
||||||
|
echo "[$(date -u '+%H:%M:%S')] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq() {
|
||||||
|
local desc="$1" expected="$2" actual="$3"
|
||||||
|
if [[ "$expected" == "$actual" ]]; then
|
||||||
|
log " ✅ PASS: $desc"
|
||||||
|
((PASS++))
|
||||||
|
else
|
||||||
|
log " ❌ FAIL: $desc (expected: $expected, got: $actual)"
|
||||||
|
((FAIL++))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_file_exists() {
|
||||||
|
local desc="$1" path="$2"
|
||||||
|
if [[ -f "$path" ]]; then
|
||||||
|
log " ✅ PASS: $desc"
|
||||||
|
((PASS++))
|
||||||
|
else
|
||||||
|
log " ❌ FAIL: $desc ($path not found)"
|
||||||
|
((FAIL++))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_executable() {
|
||||||
|
local desc="$1" path="$2"
|
||||||
|
if [[ -x "$path" ]]; then
|
||||||
|
log " ✅ PASS: $desc"
|
||||||
|
((PASS++))
|
||||||
|
else
|
||||||
|
log " ❌ FAIL: $desc ($path not executable)"
|
||||||
|
((FAIL++))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_script_syntax() {
|
||||||
|
local desc="$1" path="$2"
|
||||||
|
if bash -n "$path" 2>/dev/null; then
|
||||||
|
log " ✅ PASS: $desc (syntax OK)"
|
||||||
|
((PASS++))
|
||||||
|
else
|
||||||
|
log " ❌ FAIL: $desc (syntax error)"
|
||||||
|
((FAIL++))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_contains() {
|
||||||
|
local desc="$1" file="$2" pattern="$3"
|
||||||
|
if grep -q -- "$pattern" "$file" 2>/dev/null; then
|
||||||
|
log " ✅ PASS: $desc"
|
||||||
|
((PASS++))
|
||||||
|
else
|
||||||
|
log " ❌ FAIL: $desc (pattern '$pattern' not found in $file)"
|
||||||
|
((FAIL++))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Test: File Structure ────────────────────────────────────────
|
||||||
|
test_file_structure() {
|
||||||
|
log "=== Test: File Structure ==="
|
||||||
|
|
||||||
|
assert_file_exists "ROLLBACK.md exists" "infra/ROLLBACK.md"
|
||||||
|
assert_file_exists "rollback.sh exists" "infra/scripts/rollback.sh"
|
||||||
|
assert_file_exists "rollback-compose.sh exists" "infra/scripts/rollback-compose.sh"
|
||||||
|
assert_file_exists "rollback-migration.sh exists" "infra/scripts/rollback-migration.sh"
|
||||||
|
assert_executable "rollback.sh is executable" "infra/scripts/rollback.sh"
|
||||||
|
assert_executable "rollback-compose.sh is executable" "infra/scripts/rollback-compose.sh"
|
||||||
|
assert_executable "rollback-migration.sh is executable" "infra/scripts/rollback-migration.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Test: Script Syntax ─────────────────────────────────────────
|
||||||
|
test_script_syntax() {
|
||||||
|
log "=== Test: Script Syntax ==="
|
||||||
|
|
||||||
|
assert_script_syntax "rollback.sh syntax" "infra/scripts/rollback.sh"
|
||||||
|
assert_script_syntax "rollback-compose.sh syntax" "infra/scripts/rollback-compose.sh"
|
||||||
|
assert_script_syntax "rollback-migration.sh syntax" "infra/scripts/rollback-migration.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Test: ROLLBACK.md Content ───────────────────────────────────
|
||||||
|
test_documentation() {
|
||||||
|
log "=== Test: Documentation Content ==="
|
||||||
|
|
||||||
|
local doc="infra/ROLLBACK.md"
|
||||||
|
|
||||||
|
for section in "Overview" "ECS Service Rollback" "Docker Compose Rollback" \
|
||||||
|
"Database Migration Rollback" "Automated Rollback Triggers" \
|
||||||
|
"Blue-Green Deployment Rollback" "Rollback Decision Tree" \
|
||||||
|
"Post-Rollback Verification" "Testing Checklist" "Emergency Rollback"; do
|
||||||
|
assert_contains "Section '$section' documented" "$doc" "$section"
|
||||||
|
done
|
||||||
|
|
||||||
|
for cmd in "aws ecs update-service" "docker compose" "drizzle-kit" \
|
||||||
|
"aws rds restore-db-instance" "aws ecs wait services-stable"; do
|
||||||
|
assert_contains "Command '$cmd' documented" "$doc" "$cmd"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Test: Rollback Script Validation ────────────────────────────
|
||||||
|
test_rollback_script() {
|
||||||
|
log "=== Test: ECS Rollback Script ==="
|
||||||
|
|
||||||
|
# Test invalid environment
|
||||||
|
local exit_code=0
|
||||||
|
bash infra/scripts/rollback.sh invalid_env api >/dev/null 2>&1 || exit_code=$?
|
||||||
|
assert_eq "Invalid environment returns exit code 1" "1" "$exit_code"
|
||||||
|
|
||||||
|
# Test invalid service
|
||||||
|
exit_code=0
|
||||||
|
bash infra/scripts/rollback.sh staging invalid_svc >/dev/null 2>&1 || exit_code=$?
|
||||||
|
assert_eq "Invalid service returns exit code 1" "1" "$exit_code"
|
||||||
|
|
||||||
|
# Verify script has required functions
|
||||||
|
for func in "validate_environment" "validate_service" "rollback_service" \
|
||||||
|
"verify_health" "check_prerequisites" "main"; do
|
||||||
|
assert_contains "Function '$func' defined" "infra/scripts/rollback.sh" "$func"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Verify all services are handled
|
||||||
|
for svc in api darkwatch spamshield voiceprint; do
|
||||||
|
assert_contains "Service '$svc' in SERVICES_LIST" "infra/scripts/rollback.sh" "$svc"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Test: Compose Rollback Script ───────────────────────────────
|
||||||
|
test_compose_script() {
|
||||||
|
log "=== Test: Docker Compose Rollback Script ==="
|
||||||
|
|
||||||
|
# Test missing tag argument
|
||||||
|
local exit_code=0
|
||||||
|
bash infra/scripts/rollback-compose.sh >/dev/null 2>&1 || exit_code=$?
|
||||||
|
assert_eq "Missing tag returns exit code 1" "1" "$exit_code"
|
||||||
|
|
||||||
|
# Verify compose file exists
|
||||||
|
assert_file_exists "docker-compose.prod.yml exists" "docker-compose.prod.yml"
|
||||||
|
|
||||||
|
# Verify all services are defined in compose
|
||||||
|
for svc in api darkwatch spamshield voiceprint; do
|
||||||
|
assert_contains "Service '$svc' in docker-compose.prod.yml" "docker-compose.prod.yml" " ${svc}:"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Test: CI/CD Rollback Job ────────────────────────────────────
|
||||||
|
test_cicd_rollback() {
|
||||||
|
log "=== Test: CI/CD Rollback Configuration ==="
|
||||||
|
|
||||||
|
local deploy_wf=".github/workflows/deploy.yml"
|
||||||
|
|
||||||
|
assert_contains "Rollback job defined" "$deploy_wf" "rollback:"
|
||||||
|
assert_contains "Health check triggers rollback" "$deploy_wf" "needs.health-check.result"
|
||||||
|
assert_contains "ECS --rollback flag used" "$deploy_wf" "--rollback"
|
||||||
|
|
||||||
|
for svc in api darkwatch spamshield voiceprint; do
|
||||||
|
assert_contains "Service '$svc' in deploy matrix" "$deploy_wf" "$svc"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Test: Health Check Configuration ────────────────────────────
|
||||||
|
test_health_checks() {
|
||||||
|
log "=== Test: Health Check Configuration ==="
|
||||||
|
|
||||||
|
assert_contains "Container health check in ECS" "infra/modules/ecs/main.tf" "healthCheck"
|
||||||
|
assert_contains "ALB health check defined" "infra/modules/ecs/main.tf" "health_check"
|
||||||
|
assert_contains "ALB 5xx alarm configured" "infra/modules/cloudwatch/main.tf" "HTTPCode_Elb_5XX_Count"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Test: README References ─────────────────────────────────────
|
||||||
|
test_readme() {
|
||||||
|
log "=== Test: README References ==="
|
||||||
|
|
||||||
|
assert_contains "README references ROLLBACK.md" "infra/README.md" "ROLLBACK.md"
|
||||||
|
assert_contains "README documents rollback.sh" "infra/README.md" "rollback.sh"
|
||||||
|
assert_contains "README documents rollback-compose.sh" "infra/README.md" "rollback-compose.sh"
|
||||||
|
assert_contains "README documents rollback-migration.sh" "infra/README.md" "rollback-migration.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Main ────────────────────────────────────────────────────────
|
||||||
|
main() {
|
||||||
|
log "=== ShieldAI Rollback Test Suite ==="
|
||||||
|
log "Suite: $TEST_SUITE"
|
||||||
|
log ""
|
||||||
|
|
||||||
|
case "$TEST_SUITE" in
|
||||||
|
ecs|all)
|
||||||
|
test_rollback_script
|
||||||
|
test_cicd_rollback
|
||||||
|
test_health_checks
|
||||||
|
;;
|
||||||
|
compose|all)
|
||||||
|
test_compose_script
|
||||||
|
;;
|
||||||
|
migration)
|
||||||
|
log "=== Test: Migration Rollback ==="
|
||||||
|
assert_script_syntax "rollback-migration.sh syntax" "infra/scripts/rollback-migration.sh"
|
||||||
|
assert_contains "Uses Secrets Manager" "infra/scripts/rollback-migration.sh" "secretsmanager"
|
||||||
|
assert_contains "Uses drizzle-kit" "infra/scripts/rollback-migration.sh" "drizzle-kit"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
test_file_structure
|
||||||
|
test_script_syntax
|
||||||
|
test_documentation
|
||||||
|
test_readme
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== Results ==="
|
||||||
|
log "Passed: $PASS"
|
||||||
|
log "Failed: $FAIL"
|
||||||
|
log ""
|
||||||
|
|
||||||
|
if [[ $FAIL -gt 0 ]]; then
|
||||||
|
log "❌ SOME TESTS FAILED"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "✅ ALL TESTS PASSED"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
122
infra/variables.tf
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
variable "aws_region" {
|
||||||
|
description = "AWS region"
|
||||||
|
type = string
|
||||||
|
default = "us-east-1"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "environment" {
|
||||||
|
description = "Deployment environment"
|
||||||
|
type = string
|
||||||
|
validation {
|
||||||
|
condition = contains(["dev", "staging", "production"], var.environment)
|
||||||
|
error_message = "Environment must be one of: dev, staging, production."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "project_name" {
|
||||||
|
description = "Project name for resource naming"
|
||||||
|
type = string
|
||||||
|
default = "shieldai"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vpc_cidr" {
|
||||||
|
description = "CIDR block for VPC"
|
||||||
|
type = string
|
||||||
|
default = "10.0.0.0/16"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "az_count" {
|
||||||
|
description = "Number of availability zones"
|
||||||
|
type = number
|
||||||
|
default = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "db_name" {
|
||||||
|
description = "RDS database name"
|
||||||
|
type = string
|
||||||
|
default = "shieldai"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "db_instance_class" {
|
||||||
|
description = "RDS instance class"
|
||||||
|
type = string
|
||||||
|
default = "db.t3.medium"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "db_multi_az" {
|
||||||
|
description = "Enable Multi-AZ deployment"
|
||||||
|
type = bool
|
||||||
|
default = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "db_backup_retention" {
|
||||||
|
description = "RDS backup retention period in days"
|
||||||
|
type = number
|
||||||
|
default = 7
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "elasticache_node_type" {
|
||||||
|
description = "ElastiCache node type"
|
||||||
|
type = string
|
||||||
|
default = "cache.t3.medium"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "elasticache_num_nodes" {
|
||||||
|
description = "Number of ElastiCache nodes"
|
||||||
|
type = number
|
||||||
|
default = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "services" {
|
||||||
|
description = "ECS services to deploy"
|
||||||
|
type = map(object({
|
||||||
|
cpu = number
|
||||||
|
memory = number
|
||||||
|
port = number
|
||||||
|
}))
|
||||||
|
default = {
|
||||||
|
api = {
|
||||||
|
cpu = 512
|
||||||
|
memory = 1024
|
||||||
|
port = 3000
|
||||||
|
}
|
||||||
|
darkwatch = {
|
||||||
|
cpu = 256
|
||||||
|
memory = 512
|
||||||
|
port = 3001
|
||||||
|
}
|
||||||
|
spamshield = {
|
||||||
|
cpu = 256
|
||||||
|
memory = 512
|
||||||
|
port = 3002
|
||||||
|
}
|
||||||
|
voiceprint = {
|
||||||
|
cpu = 512
|
||||||
|
memory = 1024
|
||||||
|
port = 3003
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "container_images" {
|
||||||
|
description = "Container image tags per service"
|
||||||
|
type = map(string)
|
||||||
|
default = {
|
||||||
|
api = "latest"
|
||||||
|
darkwatch = "latest"
|
||||||
|
spamshield = "latest"
|
||||||
|
voiceprint = "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "secrets" {
|
||||||
|
description = "Secrets to store in AWS Secrets Manager"
|
||||||
|
type = map(string)
|
||||||
|
default = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "domain_name" {
|
||||||
|
description = "Route53 hosted zone domain for ACM cert validation"
|
||||||
|
type = string
|
||||||
|
default = "shieldai.app"
|
||||||
|
}
|
||||||
20
load-tests/darkwatch-auth/.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Darkwatch Auth Load Test Configuration
|
||||||
|
# Copy to .env and adjust values
|
||||||
|
|
||||||
|
# Base URL of the Darkwatch API
|
||||||
|
DARKWATCH_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Test credentials for load testing
|
||||||
|
TEST_EMAIL=loadtest@darkwatch.shieldai
|
||||||
|
TEST_PASSWORD=LoadTest2026!
|
||||||
|
|
||||||
|
# Test duration (default: 300s = 5 minutes)
|
||||||
|
DURATION=300s
|
||||||
|
|
||||||
|
# Target requests per second (default: 500)
|
||||||
|
TARGET_RPS=500
|
||||||
|
|
||||||
|
# P99 latency thresholds in milliseconds
|
||||||
|
LOGIN_P99_MS=200
|
||||||
|
LOGOUT_P99_MS=100
|
||||||
|
REFRESH_P99_MS=150
|
||||||
5
load-tests/darkwatch-auth/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# k6 load test results
|
||||||
|
results/
|
||||||
|
|
||||||
|
# Local environment overrides
|
||||||
|
.env
|
||||||
315
load-tests/darkwatch-auth/darkwatch-auth.js
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import http from 'k6/http';
|
||||||
|
import { check, sleep } from 'k6';
|
||||||
|
import { Rate, Trend } from 'k6/metrics';
|
||||||
|
|
||||||
|
// ── Configuration ────────────────────────────────────────────────────────────
|
||||||
|
const BASE_URL = __ENV.DARKWATCH_BASE_URL || 'http://localhost:3000';
|
||||||
|
const TEST_EMAIL = __ENV.TEST_EMAIL || 'loadtest@darkwatch.shieldai';
|
||||||
|
const TEST_PASSWORD = __ENV.TEST_PASSWORD || 'LoadTest2026!';
|
||||||
|
const DURATION = __ENV.DURATION || '300s'; // 5 minutes
|
||||||
|
const TARGET_RPS = parseInt(__ENV.TARGET_RPS || '500', 10);
|
||||||
|
const CREDENTIAL_POOL_SIZE = parseInt(__ENV.CREDENTIAL_POOL_SIZE || '100', 10);
|
||||||
|
|
||||||
|
// P99 latency thresholds (ms)
|
||||||
|
const THRESHOLDS = {
|
||||||
|
login: parseInt(__ENV.LOGIN_P99_MS || '200', 10),
|
||||||
|
logout: parseInt(__ENV.LOGOUT_P99_MS || '100', 10),
|
||||||
|
refresh: parseInt(__ENV.REFRESH_P99_MS || '150', 10),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Custom Metrics ───────────────────────────────────────────────────────────
|
||||||
|
const loginLatency = new Trend('login_p99');
|
||||||
|
const logoutLatency = new Trend('logout_p99');
|
||||||
|
const refreshLatency = new Trend('refresh_p99');
|
||||||
|
|
||||||
|
const loginSuccess = new Rate('login_success');
|
||||||
|
const logoutSuccess = new Rate('logout_success');
|
||||||
|
const refreshSuccess = new Rate('refresh_success');
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
function uuidv4() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
const r = (Math.random() * 16) | 0;
|
||||||
|
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeaders = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── P1#3: Fixed credential pool (reuses pre-seeded users, not unique per call) ──
|
||||||
|
const credentialPool = Array.from({ length: CREDENTIAL_POOL_SIZE }, (_, i) => ({
|
||||||
|
email: `${TEST_EMAIL.replace('@', `_${i}@`)}`,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Fake token pool fallback — used when setup() warmup is skipped or fails
|
||||||
|
const tokenPool = Array.from({ length: CREDENTIAL_POOL_SIZE }, () => ({
|
||||||
|
accessToken: uuidv4(),
|
||||||
|
refreshToken: uuidv4(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Setup: Seed real tokens via login warmup ──────────────────────────────────
|
||||||
|
export function setup() {
|
||||||
|
const creds = credentialPool[0];
|
||||||
|
const payload = JSON.stringify({ email: creds.email, password: creds.password });
|
||||||
|
const res = http.post(`${BASE_URL}/auth/login`, payload, { headers: authHeaders });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(res.body);
|
||||||
|
const accessToken = json.access_token || json.token || json.data?.access_token;
|
||||||
|
const refreshToken = json.refresh_token || json.data?.refresh_token;
|
||||||
|
|
||||||
|
if (accessToken && refreshToken) {
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
warmupSuccess: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to fake tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`[warmup] Login returned ${res.status} — standalone scenarios will use fake tokens (expect 401/403)`);
|
||||||
|
return {
|
||||||
|
accessToken: tokenPool[0].accessToken,
|
||||||
|
refreshToken: tokenPool[0].refreshToken,
|
||||||
|
warmupSuccess: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scenario: Login (POST /auth/login) ──────────────────────────────────────
|
||||||
|
function testLogin(email, password) {
|
||||||
|
const creds = email
|
||||||
|
? { email, password }
|
||||||
|
: credentialPool[Math.floor(Math.random() * credentialPool.length)];
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
email: creds.email,
|
||||||
|
password: creds.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = http.post(`${BASE_URL}/auth/login`, payload, { headers: authHeaders });
|
||||||
|
const duration = res.timings.duration;
|
||||||
|
loginLatency.add(duration);
|
||||||
|
|
||||||
|
const success = res.status === 200 || res.status === 201;
|
||||||
|
loginSuccess.add(success);
|
||||||
|
|
||||||
|
check(res, {
|
||||||
|
'login: status 200 or 201': (r) => r.status === 200 || r.status === 201,
|
||||||
|
'login: has access_token': (r) => {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(r.body);
|
||||||
|
return !!json.access_token || !!json.token || !!json.data?.access_token;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
`login: P99 < ${THRESHOLDS.login}ms`: (r) => duration < THRESHOLDS.login,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(res.body);
|
||||||
|
return {
|
||||||
|
accessToken: json.access_token || json.token || json.data?.access_token || uuidv4(),
|
||||||
|
refreshToken: json.refresh_token || json.data?.refresh_token || uuidv4(),
|
||||||
|
userId: json.user?.id || json.data?.user?.id || uuidv4(),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
accessToken: uuidv4(),
|
||||||
|
refreshToken: uuidv4(),
|
||||||
|
userId: uuidv4(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scenario: Refresh (POST /auth/refresh) ──────────────────────────────────
|
||||||
|
function testRefresh(refreshToken) {
|
||||||
|
const token = refreshToken || tokenPool[Math.floor(Math.random() * tokenPool.length)].refreshToken;
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
refresh_token: token,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = http.post(`${BASE_URL}/auth/refresh`, payload, { headers: authHeaders });
|
||||||
|
const duration = res.timings.duration;
|
||||||
|
refreshLatency.add(duration);
|
||||||
|
|
||||||
|
const success = res.status === 200;
|
||||||
|
refreshSuccess.add(success);
|
||||||
|
|
||||||
|
check(res, {
|
||||||
|
'refresh: status 200': (r) => r.status === 200,
|
||||||
|
'refresh: has new access_token': (r) => {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(r.body);
|
||||||
|
return !!json.access_token || !!json.token || !!json.data?.access_token;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
`refresh: P99 < ${THRESHOLDS.refresh}ms`: (r) => duration < THRESHOLDS.refresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(res.body);
|
||||||
|
return {
|
||||||
|
accessToken: json.access_token || json.token || json.data?.access_token || uuidv4(),
|
||||||
|
refreshToken: json.refresh_token || json.data?.refresh_token || token,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
accessToken: uuidv4(),
|
||||||
|
refreshToken: token,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── P2#4: Scenario: Logout (POST /auth/logout) — refresh_token in body, Bearer in header ──
|
||||||
|
function testLogout(accessToken, refreshToken) {
|
||||||
|
const poolEntry = tokenPool[Math.floor(Math.random() * tokenPool.length)];
|
||||||
|
const token = accessToken || poolEntry.accessToken;
|
||||||
|
const refreshTkn = refreshToken || poolEntry.refreshToken;
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
refresh_token: refreshTkn,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = http.post(`${BASE_URL}/auth/logout`, payload, {
|
||||||
|
headers: {
|
||||||
|
...authHeaders,
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const duration = res.timings.duration;
|
||||||
|
logoutLatency.add(duration);
|
||||||
|
|
||||||
|
const success = res.status === 200 || res.status === 204;
|
||||||
|
logoutSuccess.add(success);
|
||||||
|
|
||||||
|
check(res, {
|
||||||
|
'logout: status 200 or 204': (r) => r.status === 200 || r.status === 204,
|
||||||
|
`logout: P99 < ${THRESHOLDS.logout}ms`: (r) => duration < THRESHOLDS.logout,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── P1#1 + P1#2: Options with all scenarios merged (each iteration = 1 HTTP call) ──
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
sustained_load: {
|
||||||
|
executor: 'constant-arrival-rate',
|
||||||
|
duration: DURATION,
|
||||||
|
rate: TARGET_RPS,
|
||||||
|
preAllocatedVUs: 20,
|
||||||
|
maxVUs: 100,
|
||||||
|
startTime: '0s',
|
||||||
|
exec: 'mixedWorkload',
|
||||||
|
tags: { scenario: 'sustained_load' },
|
||||||
|
},
|
||||||
|
login_only: {
|
||||||
|
executor: 'constant-arrival-rate',
|
||||||
|
duration: DURATION,
|
||||||
|
rate: TARGET_RPS,
|
||||||
|
preAllocatedVUs: 20,
|
||||||
|
maxVUs: 100,
|
||||||
|
exec: 'loginOnly',
|
||||||
|
startTime: '0s',
|
||||||
|
tags: { scenario: 'login_only' },
|
||||||
|
},
|
||||||
|
logout_only: {
|
||||||
|
executor: 'constant-arrival-rate',
|
||||||
|
duration: DURATION,
|
||||||
|
rate: TARGET_RPS,
|
||||||
|
preAllocatedVUs: 20,
|
||||||
|
maxVUs: 100,
|
||||||
|
exec: 'logoutOnly',
|
||||||
|
startTime: '0s',
|
||||||
|
tags: { scenario: 'logout_only' },
|
||||||
|
},
|
||||||
|
refresh_only: {
|
||||||
|
executor: 'constant-arrival-rate',
|
||||||
|
duration: DURATION,
|
||||||
|
rate: TARGET_RPS,
|
||||||
|
preAllocatedVUs: 20,
|
||||||
|
maxVUs: 100,
|
||||||
|
exec: 'refreshOnly',
|
||||||
|
startTime: '0s',
|
||||||
|
tags: { scenario: 'refresh_only' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
`login_p99`: [`p(99)<${THRESHOLDS.login}`],
|
||||||
|
`logout_p99`: [`p(99)<${THRESHOLDS.logout}`],
|
||||||
|
`refresh_p99`: [`p(99)<${THRESHOLDS.refresh}`],
|
||||||
|
`login_success`: ['rate>0.95'],
|
||||||
|
`logout_success`: ['rate>0.95'],
|
||||||
|
`refresh_success`: ['rate>0.95'],
|
||||||
|
http_req_duration: [`p(95)<300`, `p(99)<400`],
|
||||||
|
http_req_failed: ['rate<0.05'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// P1#1: Mixed workload — exactly 1 HTTP call per iteration, weighted 40/35/25
|
||||||
|
export function mixedWorkload() {
|
||||||
|
const rand = Math.random();
|
||||||
|
|
||||||
|
if (rand < 0.4) {
|
||||||
|
testLogin();
|
||||||
|
} else if (rand < 0.75) {
|
||||||
|
testRefresh();
|
||||||
|
} else {
|
||||||
|
testLogout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual endpoint scenarios — each makes exactly 1 HTTP call per iteration
|
||||||
|
// NOTE: constant-arrival-rate executor does not pass setup() data to scenario functions.
|
||||||
|
// Standalone runs always use fake tokens (expected 401/403). For real-token testing,
|
||||||
|
// run as part of the mixedWorkload scenario or switch to vus executor.
|
||||||
|
export function loginOnly() {
|
||||||
|
testLogin();
|
||||||
|
sleep(0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logoutOnly() {
|
||||||
|
const poolEntry = tokenPool[Math.floor(Math.random() * tokenPool.length)];
|
||||||
|
console.warn('[logoutOnly] Using fake token (constant-arrival-rate does not pass setup() data)');
|
||||||
|
testLogout(poolEntry.accessToken, poolEntry.refreshToken);
|
||||||
|
sleep(0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshOnly() {
|
||||||
|
const poolEntry = tokenPool[Math.floor(Math.random() * tokenPool.length)];
|
||||||
|
console.warn('[refreshOnly] Using fake token (constant-arrival-rate does not pass setup() data)');
|
||||||
|
testRefresh(poolEntry.refreshToken);
|
||||||
|
sleep(0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Summary Hook ─────────────────────────────────────────────────────────────
|
||||||
|
export function handleSummary(data) {
|
||||||
|
// P2#5: Only evaluate metrics that have thresholds defined
|
||||||
|
const thresholdedMetrics = Object.entries(data.metrics).filter(
|
||||||
|
([_, metric]) => metric && metric.thresholds && metric.thresholds.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
const passed = thresholdedMetrics.every(([_, metric]) =>
|
||||||
|
metric.thresholds.every((t) => t.pass)
|
||||||
|
);
|
||||||
|
|
||||||
|
const loginP99 = data.metrics.login_p99?.values['p(99)']?.toFixed(2) || 'N/A';
|
||||||
|
const logoutP99 = data.metrics.logout_p99?.values['p(99)']?.toFixed(2) || 'N/A';
|
||||||
|
const refreshP99 = data.metrics.refresh_p99?.values['p(99)']?.toFixed(2) || 'N/A';
|
||||||
|
|
||||||
|
return {
|
||||||
|
'stdout': `\n=== Darkwatch Auth Load Test Results ===\n` +
|
||||||
|
`Login P99: ${loginP99}ms (threshold: ${THRESHOLDS.login}ms)\n` +
|
||||||
|
`Logout P99: ${logoutP99}ms (threshold: ${THRESHOLDS.logout}ms)\n` +
|
||||||
|
`Refresh P99: ${refreshP99}ms (threshold: ${THRESHOLDS.refresh}ms)\n` +
|
||||||
|
`Overall: ${passed ? 'PASS' : 'FAIL'}\n`,
|
||||||
|
};
|
||||||
|
}
|
||||||
70
load-tests/darkwatch-auth/run.sh
Executable file
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run k6 load tests for Darkwatch authentication endpoints
|
||||||
|
# Usage: ./run.sh [scenario]
|
||||||
|
# scenario: mixed (default), login, logout, refresh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# Load environment variables from .env if present
|
||||||
|
if [[ -f .env ]]; then
|
||||||
|
set -a
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCENARIO="${1:-mixed}"
|
||||||
|
OUTPUT_DIR="${SCRIPT_DIR}/results"
|
||||||
|
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
echo "=== Darkwatch Auth Load Test ==="
|
||||||
|
echo "Scenario: $SCENARIO"
|
||||||
|
echo "Target RPS: ${TARGET_RPS:-500}"
|
||||||
|
echo "Duration: ${DURATION:-300s}"
|
||||||
|
echo "Base URL: ${DARKWATCH_BASE_URL:-http://localhost:3000}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
EXIT_CODE=0
|
||||||
|
case "$SCENARIO" in
|
||||||
|
mixed)
|
||||||
|
k6 run darkwatch-auth.js \
|
||||||
|
--summary-export "$OUTPUT_DIR/summary-${TIMESTAMP}.json" \
|
||||||
|
--out json="$OUTPUT_DIR/results-${TIMESTAMP}.json" || EXIT_CODE=$?
|
||||||
|
;;
|
||||||
|
login)
|
||||||
|
k6 run --scenario login_only darkwatch-auth.js \
|
||||||
|
--summary-export "$OUTPUT_DIR/summary-${TIMESTAMP}.json" \
|
||||||
|
--out json="$OUTPUT_DIR/results-${TIMESTAMP}.json" || EXIT_CODE=$?
|
||||||
|
;;
|
||||||
|
logout)
|
||||||
|
k6 run --scenario logout_only darkwatch-auth.js \
|
||||||
|
--summary-export "$OUTPUT_DIR/summary-${TIMESTAMP}.json" \
|
||||||
|
--out json="$OUTPUT_DIR/results-${TIMESTAMP}.json" || EXIT_CODE=$?
|
||||||
|
;;
|
||||||
|
refresh)
|
||||||
|
k6 run --scenario refresh_only darkwatch-auth.js \
|
||||||
|
--summary-export "$OUTPUT_DIR/summary-${TIMESTAMP}.json" \
|
||||||
|
--out json="$OUTPUT_DIR/results-${TIMESTAMP}.json" || EXIT_CODE=$?
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown scenario: $SCENARIO"
|
||||||
|
echo "Available: mixed, login, logout, refresh"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ $EXIT_CODE -eq 0 ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "✅ All thresholds passed!"
|
||||||
|
echo "Results saved to: $OUTPUT_DIR/results-${TIMESTAMP}.json"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "❌ Thresholds failed. Check output above."
|
||||||
|
echo "Results saved to: $OUTPUT_DIR/results-${TIMESTAMP}.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit $EXIT_CODE
|
||||||
19
load-tests/voiceprint/.env.example
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Voiceprint Load Test Configuration
|
||||||
|
# Copy to .env and adjust values
|
||||||
|
|
||||||
|
# Base URL of the Voiceprint API
|
||||||
|
VOICEPRINT_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# API authentication token
|
||||||
|
API_TOKEN=test-token
|
||||||
|
|
||||||
|
# Test duration (default: 300s = 5 minutes)
|
||||||
|
DURATION=300s
|
||||||
|
|
||||||
|
# Target requests per second (default: 500)
|
||||||
|
TARGET_RPS=500
|
||||||
|
|
||||||
|
# P99 latency thresholds in milliseconds
|
||||||
|
ENROLLMENT_P99_MS=500
|
||||||
|
VERIFICATION_P99_MS=250
|
||||||
|
MODEL_RETRIEVAL_P99_MS=100
|
||||||
69
load-tests/voiceprint/run.sh
Executable file
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run k6 load tests for Voiceprint endpoints
|
||||||
|
# Usage: ./run.sh [scenario]
|
||||||
|
# scenario: mixed (default), enrollment, verification, model-retrieval
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# Load environment variables from .env if present
|
||||||
|
if [[ -f .env ]]; then
|
||||||
|
set -a
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCENARIO="${1:-mixed}"
|
||||||
|
OUTPUT_DIR="${SCRIPT_DIR}/results"
|
||||||
|
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
echo "=== Voiceprint Load Test ==="
|
||||||
|
echo "Scenario: $SCENARIO"
|
||||||
|
echo "Target RPS: ${TARGET_RPS:-500}"
|
||||||
|
echo "Duration: ${DURATION:-300s}"
|
||||||
|
echo "Base URL: ${VOICEPRINT_BASE_URL:-http://localhost:3000}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
case "$SCENARIO" in
|
||||||
|
mixed)
|
||||||
|
k6 run voiceprint.js \
|
||||||
|
--out json="$OUTPUT_DIR/results-${TIMESTAMP}.json" \
|
||||||
|
<<EOF
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
enrollment)
|
||||||
|
k6 run --scenario enrollment_only voiceprint.js \
|
||||||
|
--out json="$OUTPUT_DIR/results-${TIMESTAMP}.json"
|
||||||
|
;;
|
||||||
|
verification)
|
||||||
|
k6 run --scenario verification_only voiceprint.js \
|
||||||
|
--out json="$OUTPUT_DIR/results-${TIMESTAMP}.json"
|
||||||
|
;;
|
||||||
|
model-retrieval)
|
||||||
|
k6 run --scenario model_retrieval_only voiceprint.js \
|
||||||
|
--out json="$OUTPUT_DIR/results-${TIMESTAMP}.json"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown scenario: $SCENARIO"
|
||||||
|
echo "Available: mixed, enrollment, verification, model-retrieval"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
EXIT_CODE=$?
|
||||||
|
|
||||||
|
if [[ $EXIT_CODE -eq 0 ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "✅ All thresholds passed!"
|
||||||
|
echo "Results saved to: $OUTPUT_DIR/results-${TIMESTAMP}.json"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "❌ Thresholds failed. Check output above."
|
||||||
|
echo "Results saved to: $OUTPUT_DIR/results-${TIMESTAMP}.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit $EXIT_CODE
|
||||||
259
load-tests/voiceprint/voiceprint.js
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import http from 'k6/http';
|
||||||
|
import { check, sleep } from 'k6';
|
||||||
|
import { Rate, Trend } from 'k6/metrics';
|
||||||
|
|
||||||
|
// ── Configuration ────────────────────────────────────────────────────────────
|
||||||
|
const BASE_URL = __ENV.VOICEPRINT_BASE_URL || 'http://localhost:3000';
|
||||||
|
const API_TOKEN = __ENV.API_TOKEN || 'test-token';
|
||||||
|
const DURATION = __ENV.DURATION || '300s'; // 5 minutes
|
||||||
|
const TARGET_RPS = parseInt(__ENV.TARGET_RPS || '500', 10);
|
||||||
|
|
||||||
|
// P99 latency thresholds (ms)
|
||||||
|
const THRESHOLDS = {
|
||||||
|
enrollment: parseInt(__ENV.ENROLLMENT_P99_MS || '500', 10),
|
||||||
|
verification: parseInt(__ENV.VERIFICATION_P99_MS || '250', 10),
|
||||||
|
modelRetrieval: parseInt(__ENV.MODEL_RETRIEVAL_P99_MS || '100', 10),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Custom Metrics ───────────────────────────────────────────────────────────
|
||||||
|
const enrollmentLatency = new Trend('enrollment_p99');
|
||||||
|
const verificationLatency = new Trend('verification_p99');
|
||||||
|
const modelRetrievalLatency = new Trend('model_retrieval_p99');
|
||||||
|
|
||||||
|
const enrollmentSuccess = new Rate('enrollment_success');
|
||||||
|
const verificationSuccess = new Rate('verification_success');
|
||||||
|
const modelRetrievalSuccess = new Rate('model_retrieval_success');
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
function uuidv4() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
const r = (Math.random() * 16) | 0;
|
||||||
|
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a realistic audio payload (base64-encoded WAV-like buffer)
|
||||||
|
// ~3 seconds of 16kHz mono 16-bit audio = ~96KB
|
||||||
|
function generateAudioPayload() {
|
||||||
|
const size = 96000;
|
||||||
|
const audio = new Array(size);
|
||||||
|
for (let i = 0; i < size; i++) {
|
||||||
|
audio[i] = Math.floor(Math.random() * 256);
|
||||||
|
}
|
||||||
|
return btoa(String.fromCharCode(...audio.slice(0, 2048)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${API_TOKEN}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Scenario: Enrollment (POST /voiceprint/enroll) ──────────────────────────
|
||||||
|
function testEnrollment() {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
name: `voice_profile_${uuidv4()}`,
|
||||||
|
audio: generateAudioPayload(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = http.post(`${BASE_URL}/voiceprint/enroll`, payload, { headers });
|
||||||
|
const duration = res.timings.duration;
|
||||||
|
enrollmentLatency.add(duration);
|
||||||
|
|
||||||
|
const success = res.status === 201;
|
||||||
|
enrollmentSuccess.add(success);
|
||||||
|
|
||||||
|
check(res, {
|
||||||
|
'enrollment: status 201': (r) => r.status === 201,
|
||||||
|
'enrollment: has enrollment.id': (r) => {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(r.body);
|
||||||
|
return !!json.enrollment && !!json.enrollment.id;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
`enrollment: P99 < ${THRESHOLDS.enrollment}ms`: (r) => duration < THRESHOLDS.enrollment,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json()?.enrollment?.id || uuidv4();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scenario: Verification (POST /voiceprint/analyze) ───────────────────────
|
||||||
|
function testVerification() {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
audio: generateAudioPayload(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = http.post(`${BASE_URL}/voiceprint/analyze`, payload, { headers });
|
||||||
|
const duration = res.timings.duration;
|
||||||
|
verificationLatency.add(duration);
|
||||||
|
|
||||||
|
const success = res.status === 201;
|
||||||
|
verificationSuccess.add(success);
|
||||||
|
|
||||||
|
check(res, {
|
||||||
|
'verification: status 201': (r) => r.status === 201,
|
||||||
|
'verification: has analysis.id': (r) => {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(r.body);
|
||||||
|
return !!json.analysis && !!json.analysis.id;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
`verification: P99 < ${THRESHOLDS.verification}ms`: (r) => duration < THRESHOLDS.verification,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json()?.analysis?.id || uuidv4();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scenario: Model Retrieval (GET /voiceprint/results/:id) ─────────────────
|
||||||
|
function testModelRetrieval(modelId) {
|
||||||
|
const id = modelId || uuidv4();
|
||||||
|
const res = http.get(`${BASE_URL}/voiceprint/results/${id}`, { headers });
|
||||||
|
const duration = res.timings.duration;
|
||||||
|
modelRetrievalLatency.add(duration);
|
||||||
|
|
||||||
|
// 200 = found, 404 = not found (both valid for load testing)
|
||||||
|
const success = res.status === 200 || res.status === 404;
|
||||||
|
modelRetrievalSuccess.add(success);
|
||||||
|
|
||||||
|
check(res, {
|
||||||
|
'model_retrieval: status 200 or 404': (r) => r.status === 200 || r.status === 404,
|
||||||
|
`model_retrieval: P99 < ${THRESHOLDS.modelRetrieval}ms`: (r) => duration < THRESHOLDS.modelRetrieval,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Default Scenario: Weighted mixed workload ────────────────────────────────
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
sustained_load: {
|
||||||
|
executor: 'constant-arrival-rate',
|
||||||
|
duration: DURATION,
|
||||||
|
rate: TARGET_RPS,
|
||||||
|
preAllocatedVUs: 20,
|
||||||
|
maxVUs: 100,
|
||||||
|
startTime: '0s',
|
||||||
|
exec: 'mixedWorkload',
|
||||||
|
tags: { scenario: 'sustained_load' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
`enrollment_p99`: [`p(99)<${THRESHOLDS.enrollment}`],
|
||||||
|
`verification_p99`: [`p(99)<${THRESHOLDS.verification}`],
|
||||||
|
`model_retrieval_p99`: [`p(99)<${THRESHOLDS.modelRetrieval}`],
|
||||||
|
`enrollment_success`: ['rate>0.95'],
|
||||||
|
`verification_success`: ['rate>0.95'],
|
||||||
|
`model_retrieval_success`: ['rate>0.95'],
|
||||||
|
http_req_duration: [`p(95)<400`, `p(99)<500`],
|
||||||
|
http_req_failed: ['rate<0.05'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mixed workload: 30% enrollment, 45% verification, 25% model retrieval
|
||||||
|
export function mixedWorkload() {
|
||||||
|
const rand = Math.random();
|
||||||
|
|
||||||
|
if (rand < 0.3) {
|
||||||
|
const modelId = testEnrollment();
|
||||||
|
sleep(0.1);
|
||||||
|
testModelRetrieval(modelId);
|
||||||
|
} else if (rand < 0.75) {
|
||||||
|
const modelId = testVerification();
|
||||||
|
sleep(0.05);
|
||||||
|
testModelRetrieval(modelId);
|
||||||
|
} else {
|
||||||
|
testModelRetrieval();
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Individual endpoint scenarios for targeted testing ───────────────────────
|
||||||
|
export const endpointScenarios = {
|
||||||
|
enrollment_only: {
|
||||||
|
executor: 'constant-arrival-rate',
|
||||||
|
duration: DURATION,
|
||||||
|
rate: TARGET_RPS,
|
||||||
|
preAllocatedVUs: 20,
|
||||||
|
maxVUs: 100,
|
||||||
|
exec: 'enrollmentOnly',
|
||||||
|
startTime: '0s',
|
||||||
|
tags: { scenario: 'enrollment_only' },
|
||||||
|
},
|
||||||
|
verification_only: {
|
||||||
|
executor: 'constant-arrival-rate',
|
||||||
|
duration: DURATION,
|
||||||
|
rate: TARGET_RPS,
|
||||||
|
preAllocatedVUs: 20,
|
||||||
|
maxVUs: 100,
|
||||||
|
exec: 'verificationOnly',
|
||||||
|
startTime: '0s',
|
||||||
|
tags: { scenario: 'verification_only' },
|
||||||
|
},
|
||||||
|
model_retrieval_only: {
|
||||||
|
executor: 'constant-arrival-rate',
|
||||||
|
duration: DURATION,
|
||||||
|
rate: TARGET_RPS,
|
||||||
|
preAllocatedVUs: 20,
|
||||||
|
maxVUs: 100,
|
||||||
|
exec: 'modelRetrievalOnly',
|
||||||
|
startTime: '0s',
|
||||||
|
tags: { scenario: 'model_retrieval_only' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function enrollmentOnly() {
|
||||||
|
testEnrollment();
|
||||||
|
sleep(0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verificationOnly() {
|
||||||
|
testVerification();
|
||||||
|
sleep(0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modelRetrievalOnly() {
|
||||||
|
testModelRetrieval();
|
||||||
|
sleep(0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Summary Hook ─────────────────────────────────────────────────────────────
|
||||||
|
export function handleSummary(data) {
|
||||||
|
return {
|
||||||
|
'stdout': `\n=== Voiceprint Load Test Results ===\n`,
|
||||||
|
'summary.json': JSON.stringify({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
duration: DURATION,
|
||||||
|
targetRPS: TARGET_RPS,
|
||||||
|
thresholds: THRESHOLDS,
|
||||||
|
metrics: {
|
||||||
|
enrollment: {
|
||||||
|
p99: data.metrics.enrollment_p99?.values['p(99)']?.toFixed(2) || 'N/A',
|
||||||
|
p95: data.metrics.enrollment_p99?.values['p(95)']?.toFixed(2) || 'N/A',
|
||||||
|
avg: data.metrics.enrollment_p99?.values.avg?.toFixed(2) || 'N/A',
|
||||||
|
count: data.metrics.enrollment_p99?.values.count || 0,
|
||||||
|
successRate: (data.metrics.enrollment_success?.values.rate || 0) * 100 + '%',
|
||||||
|
},
|
||||||
|
verification: {
|
||||||
|
p99: data.metrics.verification_p99?.values['p(99)']?.toFixed(2) || 'N/A',
|
||||||
|
p95: data.metrics.verification_p99?.values['p(95)']?.toFixed(2) || 'N/A',
|
||||||
|
avg: data.metrics.verification_p99?.values.avg?.toFixed(2) || 'N/A',
|
||||||
|
count: data.metrics.verification_p99?.values.count || 0,
|
||||||
|
successRate: (data.metrics.verification_success?.values.rate || 0) * 100 + '%',
|
||||||
|
},
|
||||||
|
modelRetrieval: {
|
||||||
|
p99: data.metrics.model_retrieval_p99?.values['p(99)']?.toFixed(2) || 'N/A',
|
||||||
|
p95: data.metrics.model_retrieval_p99?.values['p(95)']?.toFixed(2) || 'N/A',
|
||||||
|
avg: data.metrics.model_retrieval_p99?.values.avg?.toFixed(2) || 'N/A',
|
||||||
|
count: data.metrics.model_retrieval_p99?.values.count || 0,
|
||||||
|
successRate: (data.metrics.model_retrieval_success?.values.rate || 0) * 100 + '%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
passed: Object.entries(data.metrics).every(
|
||||||
|
([_, metric]) => metric?.thresholds?.every?.((t) => t.pass)
|
||||||
|
),
|
||||||
|
}, null, 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# 2026-04-29
|
|
||||||
|
|
||||||
## Security Review: FRE-4472 (SpamShield MVP)
|
|
||||||
|
|
||||||
### Summary
|
|
||||||
Security review completed for FRE-4472 (SpamShield MVP). Total of **16 findings** identified:
|
|
||||||
- **6 HIGH** priority
|
|
||||||
- **5 MEDIUM** priority
|
|
||||||
- **5 LOW** priority
|
|
||||||
|
|
||||||
### Action Taken
|
|
||||||
Created 16 child issues to track remediation:
|
|
||||||
- **FRE-4503** through **FRE-4518**
|
|
||||||
|
|
||||||
### Current State
|
|
||||||
Parent issue **FRE-4472** is now **blocked** pending resolution of HIGH priority child issues.
|
|
||||||
|
|
||||||
### Next Action
|
|
||||||
Begin remediation with **FRE-4503** (field-level encryption) as the first HIGH priority item.
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
# 2026-05-01
|
|
||||||
|
|
||||||
## FRE-4499: SpamShield Real-Time Interception
|
|
||||||
|
|
||||||
### Completed Work
|
|
||||||
|
|
||||||
Implemented Phase 1 & 2 of the real-time interception engine:
|
|
||||||
|
|
||||||
#### Carrier API Integration
|
|
||||||
- Created carrier types interface (`carrier-types.ts`)
|
|
||||||
- Implemented Twilio carrier (`twilio-carrier.ts`) - 6KB
|
|
||||||
- Implemented Plivo carrier (`plivo-carrier.ts`) - 6KB
|
|
||||||
- Created carrier factory for carrier management (`carrier-factory.ts`)
|
|
||||||
- All carriers implement `CarrierApi` interface with block/flag/allow operations
|
|
||||||
|
|
||||||
#### Decision Engine
|
|
||||||
- Implemented multi-layer scoring decision engine (`decision-engine.ts`) - 8KB
|
|
||||||
- Reputation weight: 40%
|
|
||||||
- Rule weight: 30%
|
|
||||||
- Behavioral weight: 20%
|
|
||||||
- User history weight: 10%
|
|
||||||
- Thresholds: BLOCK >= 0.85, FLAG >= 0.60, ALLOW < 0.60
|
|
||||||
- Implemented rule engine for pattern matching (`rule-engine.ts`) - 4KB
|
|
||||||
- Supports number pattern, behavioral, and content rules
|
|
||||||
- Rule caching with TTL
|
|
||||||
|
|
||||||
#### WebSocket Alert Server
|
|
||||||
- Implemented real-time alert broadcasting (`alert-server.ts`) - 8KB
|
|
||||||
- Client subscription management
|
|
||||||
- Heartbeat support
|
|
||||||
- Event filtering by type
|
|
||||||
|
|
||||||
#### Service Integration
|
|
||||||
- Extended `SpamShieldService` with:
|
|
||||||
- `initializeCarrierFactory()` - Carrier setup
|
|
||||||
- `initializeDecisionEngine()` - Decision engine setup
|
|
||||||
- `initializeAlertServer()` - WebSocket server setup
|
|
||||||
- `interceptCall()` - Real-time call interception
|
|
||||||
- `interceptSms()` - Real-time SMS interception
|
|
||||||
- `executeCarrierAction()` - Execute carrier-specific actions
|
|
||||||
- `broadcastDecision()` - Broadcast decisions via WebSocket
|
|
||||||
|
|
||||||
### Files Created
|
|
||||||
- `services/spamshield/src/carriers/` (5 files, 16KB total)
|
|
||||||
- `services/spamshield/src/engine/` (3 files, 8KB total)
|
|
||||||
- `services/spamshield/src/websocket/` (2 files, 8KB total)
|
|
||||||
|
|
||||||
### Files Modified
|
|
||||||
- `services/spamshield/src/services/spamshield.service.ts` (+150 lines)
|
|
||||||
- `services/spamshield/src/index.ts` (added exports)
|
|
||||||
- `services/spamshield/package.json` (added ws dependency)
|
|
||||||
- `plans/FRE-4499-implementation-plan.md` (updated progress)
|
|
||||||
|
|
||||||
### Typecheck Status
|
|
||||||
- 27 TypeScript errors identified
|
|
||||||
- Main issues:
|
|
||||||
- `RequestInit` timeout property (Node.js specific)
|
|
||||||
- Optional field handling in carrier responses
|
|
||||||
- Missing `category` field in SpamRule schema
|
|
||||||
- All errors are type-safety improvements, not logic bugs
|
|
||||||
|
|
||||||
### Status
|
|
||||||
Issue FRE-4499 moved to `in_review` for Code Reviewer.
|
|
||||||
|
|
||||||
### Next Steps
|
|
||||||
1. Fix TypeScript type errors
|
|
||||||
2. Add integration tests
|
|
||||||
3. Performance validation (<200ms latency)
|
|
||||||
4. Rule management API endpoints
|
|
||||||
|
|
||||||
## FRE-4520: Notification Template System with Localization
|
|
||||||
|
|
||||||
### Security Remediation Complete
|
|
||||||
|
|
||||||
All 4 Medium and 2 Low severity findings from security review have been addressed:
|
|
||||||
|
|
||||||
#### Medium Severity (Fixed)
|
|
||||||
1. **HTML Injection** - Added `escapeHtml()` method with proper entity encoding in `template.service.ts`
|
|
||||||
2. **Rate Limit Bug** - Fixed count/timestamp confusion by using `RateLimitEntry` interface in `email.service.ts`
|
|
||||||
3. **Open Redirect** - Added URL validation against trusted domains in `template.service.ts`
|
|
||||||
4. **Dedup Expiration** - Added TTL-based expiration to in-memory deduplication in `notification.service.ts`
|
|
||||||
|
|
||||||
#### Low Severity (Fixed)
|
|
||||||
5. **Zod Validation** - Now using `NotificationConfigSchema.parse()` in `notification.config.ts`
|
|
||||||
6. **Email Validation** - Added `EMAIL_PATTERN` regex validation in `email.service.ts`
|
|
||||||
|
|
||||||
### Test Results
|
|
||||||
- All 29 tests passing ✅
|
|
||||||
- Commit: c490735
|
|
||||||
|
|
||||||
### Status
|
|
||||||
Issue updated to `in_review` and reassigned to Code Reviewer (f274248f-c47e-4f79-98ad-45919d951aa0) at 2026-05-02T00:05:37.
|
|
||||||
Comment posted: "Security remediation complete (c490735). All 4 Medium + 2 Low findings fixed. 29/29 tests passing."
|
|
||||||
Next: Waiting for Code Reviewer to complete review and assign to Security Reviewer.
|
|
||||||
|
|
||||||
## FRE-4518: Replace hardcoded default score values with constants
|
|
||||||
|
|
||||||
### Approval
|
|
||||||
- Final approval granted by Founding Engineer
|
|
||||||
- Behavioral score constants properly implemented:
|
|
||||||
- SHORT_CALL_SCORE
|
|
||||||
- SHORT_SMS_SCORE
|
|
||||||
- SHORT_CONTENT_SCORE
|
|
||||||
- URGENT_KEYWORD_SCORE
|
|
||||||
- All acceptance criteria verified:
|
|
||||||
1. ✅ Extracted default scores to constants
|
|
||||||
2. ✅ Used constants throughout codebase
|
|
||||||
3. ✅ Documented constant values and purpose
|
|
||||||
- Issue marked as `done`
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# 2026-05-02
|
|
||||||
|
|
||||||
## Code Review Activity
|
|
||||||
|
|
||||||
### FRE-4493 - Build API gateway with rate limiting and routing
|
|
||||||
|
|
||||||
**Review completed.** ✅ **Approved** with production notes.
|
|
||||||
|
|
||||||
**Delivered**: Fastify API gateway with:
|
|
||||||
- Request ID middleware and correlation
|
|
||||||
- Service routing (DarkWatch, VoicePrint, Correlation)
|
|
||||||
- CORS and Helmet security headers
|
|
||||||
- Health check endpoint
|
|
||||||
- Docker containerization
|
|
||||||
|
|
||||||
**Production Gaps**: Rate limiting middleware not yet registered, JWT verification pending, production CORS configuration needed.
|
|
||||||
|
|
||||||
**Artifacts**:
|
|
||||||
- Review doc: `/FRE/packages/api/docs/FRE-4493-review.md`
|
|
||||||
- Commit: `03276dd`
|
|
||||||
|
|
||||||
**Status:** `done`
|
|
||||||
|
|
||||||
### FRE-4507 - Implement Redis rate limiting middleware
|
|
||||||
|
|
||||||
**Review pending.** Issue marked `in_review` by Senior Engineer (f4390417-0383-406e-b4bf-37b3fa6162b8) but implementation incomplete:
|
|
||||||
|
|
||||||
- Claimed files in `apps/api/src/` but repo uses `packages/api/` + `services/spamshield/`
|
|
||||||
- `spamshield.config.ts` lacks per-minute/daily rate limit structure
|
|
||||||
- Missing: `spam-rate-limit.middleware.ts`, `spamshield.routes.ts`
|
|
||||||
- Redis service exists in `packages/shared-notifications/` but not integrated
|
|
||||||
|
|
||||||
**Action:** Awaiting Senior Engineer (d20f6f1c-1f24-4405-a122-2f93e0d6c94a) to complete implementation.
|
|
||||||
|
|
||||||
**Status:** `in_progress`
|
|
||||||
10
package.json
@@ -17,13 +17,17 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"vitest": "^4.1.5",
|
"@types/ws": "^8.5.10",
|
||||||
"@vitest/coverage-v8": "^4.1.5",
|
"@vitest/coverage-v8": "^4.1.5",
|
||||||
"turbo": "^2.3.0",
|
"turbo": "^2.3.0",
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.0.0"
|
"packageManager": "pnpm@9.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.16.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ FROM node:20-alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json turbo.json ./
|
COPY package.json pnpm-lock.yaml turbo.json pnpm-workspace.yaml ./
|
||||||
COPY packages/api/package.json ./packages/api/
|
COPY packages/api/package.json ./packages/api/
|
||||||
COPY packages/db/package.json ./packages/db/
|
COPY packages/db/package.json ./packages/db/
|
||||||
COPY packages/types/package.json ./packages/types/
|
COPY packages/types/package.json ./packages/types/
|
||||||
@@ -13,7 +13,7 @@ COPY services/darkwatch/package.json ./services/darkwatch/
|
|||||||
COPY services/spamshield/package.json ./services/spamshield/
|
COPY services/spamshield/package.json ./services/spamshield/
|
||||||
COPY services/voiceprint/package.json ./services/voiceprint/
|
COPY services/voiceprint/package.json ./services/voiceprint/
|
||||||
|
|
||||||
RUN npm ci
|
RUN npm i -g pnpm@9 && pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY tsconfig.json ./
|
COPY tsconfig.json ./
|
||||||
COPY packages/api/tsconfig.json ./packages/api/
|
COPY packages/api/tsconfig.json ./packages/api/
|
||||||
@@ -23,7 +23,7 @@ COPY packages/api/ ./packages/api/
|
|||||||
COPY packages/db/ ./packages/db/
|
COPY packages/db/ ./packages/db/
|
||||||
COPY packages/types/ ./packages/types/
|
COPY packages/types/ ./packages/types/
|
||||||
|
|
||||||
RUN npm run build --workspace=@shieldai/types --workspace=@shieldai/db --workspace=@shieldai/api
|
RUN pnpm build --filter=@shieldai/types --filter=@shieldai/db --filter=@shieldai/api
|
||||||
|
|
||||||
FROM node:20-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
|
|
||||||
|
|||||||
@@ -12,17 +12,23 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^10.0.1",
|
"@fastify/cors": "^10.0.1",
|
||||||
"@fastify/helmet": "^13.0.1",
|
"@fastify/helmet": "^13.0.1",
|
||||||
|
"@fastify/multipart": "^7.7.3",
|
||||||
"@fastify/rate-limit": "^9.0.0",
|
"@fastify/rate-limit": "^9.0.0",
|
||||||
"@fastify/sensible": "^6.0.1",
|
"@fastify/sensible": "^6.0.1",
|
||||||
"@shieldai/db": "workspace:*",
|
|
||||||
"@shieldai/types": "workspace:*",
|
|
||||||
"@shieldai/correlation": "workspace:*",
|
"@shieldai/correlation": "workspace:*",
|
||||||
"fastify": "^5.2.0",
|
|
||||||
"@shieldai/darkwatch": "workspace:*",
|
"@shieldai/darkwatch": "workspace:*",
|
||||||
"@shieldai/voiceprint": "workspace:*"
|
"@shieldai/db": "workspace:*",
|
||||||
|
"@shieldai/monitoring": "workspace:*",
|
||||||
|
"@shieldai/report": "workspace:*",
|
||||||
|
"@shieldai/shared-notifications": "workspace:*",
|
||||||
|
"@shieldai/types": "workspace:*",
|
||||||
|
"@shieldai/voiceprint": "workspace:*",
|
||||||
|
"bullmq": "^5.24.0",
|
||||||
|
"fastify": "^5.2.0",
|
||||||
|
"ioredis": "^5.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vitest": "^4.1.5",
|
"@vitest/coverage-v8": "^4.1.5",
|
||||||
"@vitest/coverage-v8": "^4.1.5"
|
"vitest": "^4.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const envSchema = z.object({
|
|||||||
API_RATE_LIMIT_WINDOW: z.string().transform(Number).default(60000), // 1 minute
|
API_RATE_LIMIT_WINDOW: z.string().transform(Number).default(60000), // 1 minute
|
||||||
API_RATE_LIMIT_MAX_REQUESTS: z.string().transform(Number).default(100),
|
API_RATE_LIMIT_MAX_REQUESTS: z.string().transform(Number).default(100),
|
||||||
CORS_ORIGIN: z.string().default('http://localhost:5173'),
|
CORS_ORIGIN: z.string().default('http://localhost:5173'),
|
||||||
|
ALLOWED_ORIGINS: z.string().default(''),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiEnv = envSchema.parse({
|
export const apiEnv = envSchema.parse({
|
||||||
@@ -17,8 +18,52 @@ export const apiEnv = envSchema.parse({
|
|||||||
API_RATE_LIMIT_WINDOW: process.env.API_RATE_LIMIT_WINDOW,
|
API_RATE_LIMIT_WINDOW: process.env.API_RATE_LIMIT_WINDOW,
|
||||||
API_RATE_LIMIT_MAX_REQUESTS: process.env.API_RATE_LIMIT_MAX_REQUESTS,
|
API_RATE_LIMIT_MAX_REQUESTS: process.env.API_RATE_LIMIT_MAX_REQUESTS,
|
||||||
CORS_ORIGIN: process.env.CORS_ORIGIN,
|
CORS_ORIGIN: process.env.CORS_ORIGIN,
|
||||||
|
ALLOWED_ORIGINS: process.env.ALLOWED_ORIGINS,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse ALLOWED_ORIGINS into a validated set.
|
||||||
|
* In production, rejects wildcards ('*') and empty values.
|
||||||
|
* In development, falls back to localhost.
|
||||||
|
*/
|
||||||
|
export function getCorsOrigins(): string | string[] {
|
||||||
|
const origins = (apiEnv.ALLOWED_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
if (apiEnv.NODE_ENV === 'production') {
|
||||||
|
if (origins.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
'CORS origin validation (FRE-4749): ALLOWED_ORIGINS is empty in production. ' +
|
||||||
|
'Set ALLOWED_ORIGINS to a comma-separated list of allowed origins.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const origin of origins) {
|
||||||
|
if (origin === '*') {
|
||||||
|
throw new Error(
|
||||||
|
'CORS origin validation (FRE-4749): wildcard (*) ALLOWED_ORIGIN in production.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let isValidProtocol = true;
|
||||||
|
try {
|
||||||
|
const url = new URL(origin);
|
||||||
|
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||||
|
isValidProtocol = false;
|
||||||
|
throw new Error(
|
||||||
|
`CORS origin validation (FRE-4749): invalid protocol "${url.protocol}" in "${origin}". Expected http: or https:`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && !isValidProtocol) throw err;
|
||||||
|
throw new Error(
|
||||||
|
`CORS origin validation (FRE-4749): malformed origin "${origin}": ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return origins;
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiEnv.CORS_ORIGIN || 'http://localhost:5173';
|
||||||
|
}
|
||||||
|
|
||||||
// Rate limit configuration by tier
|
// Rate limit configuration by tier
|
||||||
export const rateLimitConfig = {
|
export const rateLimitConfig = {
|
||||||
basic: {
|
basic: {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// dd-trace must be initialized before any other module is loaded for auto-instrumentation
|
||||||
|
import '@shieldai/monitoring/datadog-init';
|
||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import helmet from '@fastify/helmet';
|
import helmet from '@fastify/helmet';
|
||||||
@@ -6,7 +8,7 @@ import { rateLimitMiddleware } from './middleware/rate-limit.middleware';
|
|||||||
import { spamRateLimitMiddleware } from './middleware/spam-rate-limit.middleware';
|
import { spamRateLimitMiddleware } from './middleware/spam-rate-limit.middleware';
|
||||||
import { errorHandlingMiddleware } from './middleware/error-handling.middleware';
|
import { errorHandlingMiddleware } from './middleware/error-handling.middleware';
|
||||||
import { loggingMiddleware } from './middleware/logging.middleware';
|
import { loggingMiddleware } from './middleware/logging.middleware';
|
||||||
import { apiEnv, loggingConfig } from './config/api.config';
|
import { apiEnv, loggingConfig, getCorsOrigins } from './config/api.config';
|
||||||
import { routes } from './routes';
|
import { routes } from './routes';
|
||||||
|
|
||||||
const fastify = Fastify({
|
const fastify = Fastify({
|
||||||
@@ -19,7 +21,7 @@ const fastify = Fastify({
|
|||||||
async function registerPlugins() {
|
async function registerPlugins() {
|
||||||
// CORS configuration
|
// CORS configuration
|
||||||
await fastify.register(cors, {
|
await fastify.register(cors, {
|
||||||
origin: apiEnv.CORS_ORIGIN,
|
origin: getCorsOrigins(),
|
||||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|||||||
209
packages/api/src/lib/phishing-detector.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
export enum UrlVerdict {
|
||||||
|
SAFE = 'safe',
|
||||||
|
SUSPICIOUS = 'suspicious',
|
||||||
|
PHISHING = 'phishing',
|
||||||
|
SPAM = 'spam',
|
||||||
|
EXPOSED_CREDENTIALS = 'exposed_credentials',
|
||||||
|
UNKNOWN = 'unknown',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ThreatType {
|
||||||
|
PHISHING_KNOWN = 'phishing_known',
|
||||||
|
PHISHING_HEURISTIC = 'phishing_heuristic',
|
||||||
|
DOMAIN_AGE = 'domain_age',
|
||||||
|
SSL_ANOMALY = 'ssl_anomaly',
|
||||||
|
URL_ENTROPY = 'url_entropy',
|
||||||
|
TYPOSQUAT = 'typosquat',
|
||||||
|
CREDENTIAL_EXPOSURE = 'credential_exposure',
|
||||||
|
SPAM_SOURCE = 'spam_source',
|
||||||
|
REDIRECT_CHAIN = 'redirect_chain',
|
||||||
|
MIXED_CONTENT = 'mixed_content',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreatInfo {
|
||||||
|
type: ThreatType;
|
||||||
|
severity: number;
|
||||||
|
source: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PhishingDetector {
|
||||||
|
private knownSuspiciousTlds = new Set([
|
||||||
|
'.tk', '.ml', '.ga', '.cf', '.gq', '.xyz', '.top', '.click', '.link', '.work',
|
||||||
|
]);
|
||||||
|
|
||||||
|
private commonBrands = new Map<string, string[]>([
|
||||||
|
['google', ['gmail', 'drive', 'docs', 'maps', 'play', 'chrome', 'youtube']],
|
||||||
|
['apple', ['icloud', 'appstore', 'icloud_content', 'appleid']],
|
||||||
|
['amazon', ['aws', 'amazonaws', 'amazon-adsystem', 'prime-video']],
|
||||||
|
['microsoft', ['office', 'outlook', 'onedrive', 'teams', 'azure', 'windows']],
|
||||||
|
['facebook', ['fb', 'fbcdn', 'instagram', 'whatsapp', 'messenger']],
|
||||||
|
['paypal', ['paypalobjects', 'paypal-web', 'xoom']],
|
||||||
|
['netflix', ['nflximg', 'nflxso', 'nflxvideo', 'nflxext']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
analyzeUrl(url: string): { verdict: UrlVerdict; threats: ThreatInfo[]; score: number } {
|
||||||
|
const threats: ThreatInfo[] = [];
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const hostname = parsed.hostname.toLowerCase();
|
||||||
|
const domainParts = hostname.split('.');
|
||||||
|
const tld = domainParts[domainParts.length - 1];
|
||||||
|
|
||||||
|
score += this.checkTld(tld, threats);
|
||||||
|
score += this.checkEntropy(parsed.pathname + parsed.search, threats);
|
||||||
|
score += this.checkTyposquatting(hostname, threats);
|
||||||
|
score += this.checkIpAddress(hostname, threats);
|
||||||
|
score += this.checkLongUrl(url, threats);
|
||||||
|
score += this.checkSubdomainDepth(domainParts, threats);
|
||||||
|
score += this.checkHttpsProtocol(parsed.protocol, threats);
|
||||||
|
score += this.checkRedirectPatterns(parsed.search, threats);
|
||||||
|
score += this.checkEncodedChars(url, threats);
|
||||||
|
score += this.checkBrandImpersonation(hostname, threats);
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
verdict: UrlVerdict.UNKNOWN,
|
||||||
|
threats: [{ type: ThreatType.PHISHING_HEURISTIC, severity: 3, source: 'heuristic', description: 'Malformed URL' }],
|
||||||
|
score: 30,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const verdict = score >= 70 ? UrlVerdict.PHISHING
|
||||||
|
: score >= 40 ? UrlVerdict.SUSPICIOUS
|
||||||
|
: score >= 20 ? UrlVerdict.SPAM
|
||||||
|
: UrlVerdict.SAFE;
|
||||||
|
|
||||||
|
return { verdict, threats, score };
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkTld(tld: string, threats: ThreatInfo[]): number {
|
||||||
|
if (this.knownSuspiciousTlds.has(`.${tld}`)) {
|
||||||
|
threats.push({ type: ThreatType.DOMAIN_AGE, severity: 4, source: 'heuristic', description: `Suspicious TLD: .${tld}` });
|
||||||
|
return 25;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkEntropy(pathname: string, threats: ThreatInfo[]): number {
|
||||||
|
if (!pathname || pathname.length < 20) return 0;
|
||||||
|
const entropy = this.calculateEntropy(pathname);
|
||||||
|
if (entropy > 4.5) {
|
||||||
|
threats.push({ type: ThreatType.URL_ENTROPY, severity: 4, source: 'heuristic', description: `High URL path entropy (${entropy.toFixed(2)})` });
|
||||||
|
return 20;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkTyposquatting(hostname: string, threats: ThreatInfo[]): number {
|
||||||
|
for (const [brand, subdomains] of this.commonBrands) {
|
||||||
|
const parts = hostname.split('.');
|
||||||
|
const main = parts[0];
|
||||||
|
if (main.includes(brand) && main !== brand) {
|
||||||
|
const dist = this.levenshteinDistance(main, brand);
|
||||||
|
if (dist <= 2 && dist > 0) {
|
||||||
|
threats.push({ type: ThreatType.TYPOSQUAT, severity: 5, source: 'heuristic', description: `Possible typosquat of "${brand}"` });
|
||||||
|
return 35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dist = this.levenshteinDistance(main, brand);
|
||||||
|
if (dist <= 2 && dist > 0 && main.length >= brand.length - 1) {
|
||||||
|
threats.push({ type: ThreatType.TYPOSQUAT, severity: 5, source: 'heuristic', description: `Possible typosquat of "${brand}"` });
|
||||||
|
return 35;
|
||||||
|
}
|
||||||
|
for (const sub of subdomains) {
|
||||||
|
if (hostname.includes(sub) && !hostname.startsWith(`${sub}.`)) {
|
||||||
|
threats.push({ type: ThreatType.TYPOSQUAT, severity: 3, source: 'heuristic', description: `Contains "${sub}" but not official ${brand}` });
|
||||||
|
return 15;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkIpAddress(hostname: string, threats: ThreatInfo[]): number {
|
||||||
|
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname) && hostname !== '127.0.0.1') {
|
||||||
|
threats.push({ type: ThreatType.PHISHING_HEURISTIC, severity: 4, source: 'heuristic', description: `IP address hostname: ${hostname}` });
|
||||||
|
return 25;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkLongUrl(url: string, threats: ThreatInfo[]): number {
|
||||||
|
if (url.length > 200) {
|
||||||
|
threats.push({ type: ThreatType.PHISHING_HEURISTIC, severity: 3, source: 'heuristic', description: `Long URL (${url.length} chars)` });
|
||||||
|
return 15;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkSubdomainDepth(parts: string[], threats: ThreatInfo[]): number {
|
||||||
|
if (parts.length > 5) {
|
||||||
|
threats.push({ type: ThreatType.PHISHING_HEURISTIC, severity: 3, source: 'heuristic', description: `Deep subdomains (${parts.length} levels)` });
|
||||||
|
return 15;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkHttpsProtocol(protocol: string, threats: ThreatInfo[]): number {
|
||||||
|
if (protocol === 'http:') {
|
||||||
|
threats.push({ type: ThreatType.MIXED_CONTENT, severity: 2, source: 'heuristic', description: 'HTTP (not HTTPS)' });
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkRedirectPatterns(query: string, threats: ThreatInfo[]): number {
|
||||||
|
const params = ['redirect', 'url', 'dest', 'return', 'next', 'target'];
|
||||||
|
const count = params.filter((p) => query.includes(`${p}=`)).length;
|
||||||
|
if (count >= 2) {
|
||||||
|
threats.push({ type: ThreatType.REDIRECT_CHAIN, severity: 3, source: 'heuristic', description: `Multiple redirect params (${count})` });
|
||||||
|
return 15;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkEncodedChars(url: string, threats: ThreatInfo[]): number {
|
||||||
|
if (/(%[0-9a-fA-F]{2}){3,}/.test(url)) {
|
||||||
|
threats.push({ type: ThreatType.URL_ENTROPY, severity: 3, source: 'heuristic', description: 'Excessive URL encoding' });
|
||||||
|
return 15;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkBrandImpersonation(hostname: string, threats: ThreatInfo[]): number {
|
||||||
|
const patterns = [/login[-_]?(secure|portal|page|form)/i, /account[-_]?(verify|confirm|update)/i, /secure[-_]?(signin|auth|login)/i];
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
if (pattern.test(hostname)) {
|
||||||
|
threats.push({ type: ThreatType.PHISHING_HEURISTIC, severity: 4, source: 'heuristic', description: `Phishing pattern: ${hostname}` });
|
||||||
|
return 20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateEntropy(str: string): number {
|
||||||
|
const freq: Record<string, number> = {};
|
||||||
|
for (const c of str) freq[c] = (freq[c] || 0) + 1;
|
||||||
|
let entropy = 0;
|
||||||
|
const len = str.length;
|
||||||
|
for (const count of Object.values(freq)) {
|
||||||
|
const p = count / len;
|
||||||
|
entropy -= p * Math.log2(p);
|
||||||
|
}
|
||||||
|
return entropy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private levenshteinDistance(a: string, b: string): number {
|
||||||
|
const m: number[][] = [];
|
||||||
|
for (let i = 0; i <= b.length; i++) m[i] = [i];
|
||||||
|
for (let j = 0; j <= a.length; j++) m[0][j] = j;
|
||||||
|
for (let i = 1; i <= b.length; i++)
|
||||||
|
for (let j = 1; j <= a.length; j++)
|
||||||
|
m[i][j] = b[i-1] === a[j-1] ? m[i-1][j-1] : Math.min(m[i-1][j-1]+1, m[i][j-1]+1, m[i-1][j]+1);
|
||||||
|
return m[b.length][a.length];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const phishingDetector = new PhishingDetector();
|
||||||
@@ -16,7 +16,7 @@ export async function authMiddleware(fastify: FastifyInstance) {
|
|||||||
fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
|
fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const authReq = request as AuthRequest;
|
const authReq = request as AuthRequest;
|
||||||
// Skip auth for health checks and root
|
// Skip auth for health checks and root
|
||||||
const publicRoutes = ['/', '/health'];
|
const publicRoutes = ['/', '/health', '/extension/auth'];
|
||||||
if (publicRoutes.some((route) => request.url.startsWith(route))) {
|
if (publicRoutes.some((route) => request.url.startsWith(route))) {
|
||||||
authReq.authType = 'anonymous';
|
authReq.authType = 'anonymous';
|
||||||
return;
|
return;
|
||||||
@@ -46,9 +46,10 @@ export async function authMiddleware(fastify: FastifyInstance) {
|
|||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
// In production, validate API key against database
|
// In production, validate API key against database
|
||||||
authReq.apiKey = apiKey;
|
authReq.apiKey = apiKey;
|
||||||
|
const apiKeyPrefix = apiKey.slice(0, 8);
|
||||||
authReq.user = {
|
authReq.user = {
|
||||||
id: `api-${apiKey}`,
|
id: `api-${apiKeyPrefix}...`,
|
||||||
email: `api-${apiKey}@services.internal`,
|
email: `api-${apiKeyPrefix}@services.internal`,
|
||||||
role: 'service',
|
role: 'service',
|
||||||
};
|
};
|
||||||
authReq.authType = 'api-key';
|
authReq.authType = 'api-key';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { captureSentryError, setSentryContext, setSentryUser } from '@shieldai/monitoring';
|
||||||
|
|
||||||
export interface ErrorResponse {
|
export interface ErrorResponse {
|
||||||
error: string;
|
error: string;
|
||||||
@@ -13,19 +14,37 @@ export interface ErrorResponse {
|
|||||||
export async function errorHandlingMiddleware(fastify: FastifyInstance) {
|
export async function errorHandlingMiddleware(fastify: FastifyInstance) {
|
||||||
// Custom error handler
|
// Custom error handler
|
||||||
fastify.setErrorHandler((error, request: FastifyRequest, reply: FastifyReply) => {
|
fastify.setErrorHandler((error, request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const err = error as Error & { statusCode?: number; code?: string };
|
||||||
const response: ErrorResponse = {
|
const response: ErrorResponse = {
|
||||||
error: error.name || 'Internal Server Error',
|
error: err.name || 'Internal Server Error',
|
||||||
message: error.message || 'An unexpected error occurred',
|
message: err.message || 'An unexpected error occurred',
|
||||||
statusCode: error.statusCode || 500,
|
statusCode: err.statusCode || 500,
|
||||||
code: (error as any).code,
|
code: err.code,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
path: request.url,
|
path: request.url,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Send to Sentry (5xx errors only)
|
||||||
|
if (response.statusCode >= 500) {
|
||||||
|
const userId = (request as FastifyRequest & { user?: { id?: string } }).user?.id;
|
||||||
|
if (userId) setSentryUser(userId);
|
||||||
|
setSentryContext('request', {
|
||||||
|
method: request.method,
|
||||||
|
url: request.url,
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
requestId: request.id,
|
||||||
|
});
|
||||||
|
captureSentryError(err, {
|
||||||
|
statusCode: String(response.statusCode),
|
||||||
|
path: request.url,
|
||||||
|
method: request.method,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Log error
|
// Log error
|
||||||
fastify.log.error({
|
fastify.log.error({
|
||||||
error: response,
|
error: response,
|
||||||
stack: error.stack,
|
stack: err.stack,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
userAgent: request.headers['user-agent'],
|
userAgent: request.headers['user-agent'],
|
||||||
});
|
});
|
||||||
|
|||||||
69
packages/api/src/middleware/monitoring.middleware.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { emitBatchMetrics, emitError } from '@shieldai/monitoring';
|
||||||
|
|
||||||
|
const SERVICE_NAME = process.env.DD_SERVICE || 'shieldai-api';
|
||||||
|
|
||||||
|
export async function monitoringMiddleware(fastify: FastifyInstance) {
|
||||||
|
fastify.addHook('onResponse', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const statusCode = reply.statusCode;
|
||||||
|
const responseTime = reply.elapsedTime;
|
||||||
|
const method = request.method;
|
||||||
|
const url = request.url;
|
||||||
|
|
||||||
|
// Batch all metrics into a single PutMetricDataCommand to avoid rate limits
|
||||||
|
await emitBatchMetrics({
|
||||||
|
serviceName: SERVICE_NAME,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
metricName: 'api_requests',
|
||||||
|
value: 1,
|
||||||
|
unit: 'Count',
|
||||||
|
dimensions: { status_class: String(Math.floor(statusCode / 100)) + 'xx' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricName: 'api_latency',
|
||||||
|
value: responseTime,
|
||||||
|
unit: 'Milliseconds',
|
||||||
|
dimensions: { percentile: 'p50' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricName: 'api_latency',
|
||||||
|
value: responseTime,
|
||||||
|
unit: 'Milliseconds',
|
||||||
|
dimensions: { percentile: 'p95' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricName: 'api_latency',
|
||||||
|
value: responseTime,
|
||||||
|
unit: 'Milliseconds',
|
||||||
|
dimensions: { percentile: 'p99' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit error metric for 5xx (separate call since it has different dimensions)
|
||||||
|
if (statusCode >= 500) {
|
||||||
|
await emitError(SERVICE_NAME, 'server_error');
|
||||||
|
fastify.log.warn({
|
||||||
|
event: 'high_latency_or_error',
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
statusCode,
|
||||||
|
responseTime,
|
||||||
|
service: SERVICE_NAME,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log high latency requests (>2s) — only when not already logged as error
|
||||||
|
else if (responseTime > 2000) {
|
||||||
|
fastify.log.warn({
|
||||||
|
event: 'high_latency',
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
statusCode,
|
||||||
|
responseTime,
|
||||||
|
service: SERVICE_NAME,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
136
packages/api/src/routes/blog-admin.routes.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { prisma } from '@shieldai/db';
|
||||||
|
|
||||||
|
interface CreatePostBody {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
excerpt?: string;
|
||||||
|
content: string;
|
||||||
|
authorName?: string;
|
||||||
|
coverImageUrl?: string;
|
||||||
|
tags?: string[];
|
||||||
|
published?: boolean;
|
||||||
|
publishedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function blogAdminRoutes(fastify: FastifyInstance) {
|
||||||
|
fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const authReq = request as FastifyRequest & { user?: { id: string; role?: string } };
|
||||||
|
const user = authReq.user;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return reply.code(401).send({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role !== 'support') {
|
||||||
|
return reply.code(403).send({ error: 'Admin access required' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post('/admin/blog', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const body = request.body as CreatePostBody;
|
||||||
|
|
||||||
|
if (!body.slug || !/^[a-z0-9-]+$/.test(body.slug)) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid slug: must be lowercase alphanumeric with hyphens' });
|
||||||
|
}
|
||||||
|
if (!body.title || body.title.length > 200) {
|
||||||
|
return reply.code(400).send({ error: 'Title is required (max 200 chars)' });
|
||||||
|
}
|
||||||
|
if (!body.content) {
|
||||||
|
return reply.code(400).send({ error: 'Content is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.blogPost.findUnique({
|
||||||
|
where: { slug: body.slug },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return reply.code(409).send({ error: 'A post with this slug already exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = await prisma.blogPost.create({
|
||||||
|
data: {
|
||||||
|
slug: body.slug,
|
||||||
|
title: body.title,
|
||||||
|
excerpt: body.excerpt || null,
|
||||||
|
content: body.content,
|
||||||
|
authorName: body.authorName || null,
|
||||||
|
coverImageUrl: body.coverImageUrl || null,
|
||||||
|
tags: body.tags || [],
|
||||||
|
published: body.published || false,
|
||||||
|
publishedAt: body.publishedAt
|
||||||
|
? new Date(body.publishedAt)
|
||||||
|
: body.published
|
||||||
|
? new Date()
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(201).send({ post });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.put('/admin/blog/:id', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const body = request.body as Partial<CreatePostBody>;
|
||||||
|
|
||||||
|
const existing = await prisma.blogPost.findUnique({ where: { id } });
|
||||||
|
if (!existing) {
|
||||||
|
return reply.code(404).send({ error: 'Post not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.slug && body.slug !== existing.slug) {
|
||||||
|
const slugExists = await prisma.blogPost.findUnique({ where: { slug: body.slug } });
|
||||||
|
if (slugExists) {
|
||||||
|
return reply.code(409).send({ error: 'A post with this slug already exists' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = await prisma.blogPost.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(body.slug !== undefined && { slug: body.slug }),
|
||||||
|
...(body.title !== undefined && { title: body.title }),
|
||||||
|
...(body.excerpt !== undefined && { excerpt: body.excerpt }),
|
||||||
|
...(body.content !== undefined && { content: body.content }),
|
||||||
|
...(body.authorName !== undefined && { authorName: body.authorName }),
|
||||||
|
...(body.coverImageUrl !== undefined && { coverImageUrl: body.coverImageUrl }),
|
||||||
|
...(body.tags !== undefined && { tags: body.tags }),
|
||||||
|
...(body.published !== undefined && { published: body.published }),
|
||||||
|
publishedAt: body.publishedAt
|
||||||
|
? new Date(body.publishedAt)
|
||||||
|
: body.published === true && !existing.published
|
||||||
|
? new Date()
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.send({ post });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.delete('/admin/blog/:id', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
await prisma.blogPost.delete({ where: { id } });
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get('/admin/blog', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const query = request.query as { page?: string; limit?: string };
|
||||||
|
const page = Math.max(1, parseInt(query.page || '1', 10));
|
||||||
|
const limit = Math.min(50, Math.max(1, parseInt(query.limit || '20', 10)));
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const [posts, total] = await Promise.all([
|
||||||
|
prisma.blogPost.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
prisma.blogPost.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
posts,
|
||||||
|
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
72
packages/api/src/routes/blog.routes.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { prisma } from '@shieldai/db';
|
||||||
|
|
||||||
|
interface BlogQuery {
|
||||||
|
page?: string;
|
||||||
|
limit?: string;
|
||||||
|
tag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function blogRoutes(fastify: FastifyInstance) {
|
||||||
|
fastify.get('/blog', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const query = request.query as BlogQuery;
|
||||||
|
const page = Math.max(1, parseInt(query.page || '1', 10));
|
||||||
|
const limit = Math.min(50, Math.max(1, parseInt(query.limit || '10', 10)));
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
published: true,
|
||||||
|
...(query.tag ? { tags: { has: query.tag } } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [posts, total] = await Promise.all([
|
||||||
|
prisma.blogPost.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { publishedAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
title: true,
|
||||||
|
excerpt: true,
|
||||||
|
authorName: true,
|
||||||
|
coverImageUrl: true,
|
||||||
|
tags: true,
|
||||||
|
publishedAt: true,
|
||||||
|
viewCount: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.blogPost.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
posts,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get('/blog/:slug', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const { slug } = request.params as { slug: string };
|
||||||
|
|
||||||
|
const post = await prisma.blogPost.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!post || !post.published) {
|
||||||
|
return reply.code(404).send({ error: 'Post not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.blogPost.update({
|
||||||
|
where: { id: post.id },
|
||||||
|
data: { viewCount: { increment: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.send({ post });
|
||||||
|
});
|
||||||
|
}
|
||||||
208
packages/api/src/routes/extension.routes.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { phishingDetector } from './lib/phishing-detector';
|
||||||
|
|
||||||
|
interface UrlCheckRequest {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PhishingReportRequest {
|
||||||
|
url: string;
|
||||||
|
pageTitle: string;
|
||||||
|
tabId: number;
|
||||||
|
timestamp: number;
|
||||||
|
reason: string;
|
||||||
|
heuristics: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extensionRoutes(fastify: FastifyInstance) {
|
||||||
|
fastify.post('/url-check', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const authReq = request as FastifyRequest & { user?: { id: string; tier?: string } };
|
||||||
|
const userId = authReq.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return reply.code(401).send({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = request.body as UrlCheckRequest;
|
||||||
|
if (!body.url) {
|
||||||
|
return reply.code(400).send({ error: 'url is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(body.url);
|
||||||
|
const heuristic = phishingDetector.analyzeUrl(body.url);
|
||||||
|
|
||||||
|
const threats = heuristic.threats.map((t) => ({
|
||||||
|
type: t.type,
|
||||||
|
severity: t.severity,
|
||||||
|
source: t.source,
|
||||||
|
description: t.description,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
url: body.url,
|
||||||
|
domain: url.hostname,
|
||||||
|
verdict: heuristic.verdict,
|
||||||
|
confidence: heuristic.score / 100,
|
||||||
|
threats,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'URL check failed';
|
||||||
|
return reply.code(500).send({ error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post('/phishing-report', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||||
|
const userId = authReq.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return reply.code(401).send({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = request.body as PhishingReportRequest;
|
||||||
|
|
||||||
|
try {
|
||||||
|
fastify.log.info({ url: body.url, userId, reason: body.reason }, 'Phishing report received');
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
reportId: `report_${Date.now()}_${userId}`,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Report submission failed';
|
||||||
|
return reply.code(500).send({ error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post('/auth', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const authHeader = request.headers.authorization;
|
||||||
|
if (!authHeader?.startsWith('Bearer ')) {
|
||||||
|
return reply.code(401).send({ error: 'Bearer token required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await validateExtensionToken(token, fastify);
|
||||||
|
return reply.send(result);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Authentication failed';
|
||||||
|
return reply.code(401).send({ error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get('/stats', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||||
|
const userId = authReq.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return reply.code(401).send({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const today = new Date().toDateString();
|
||||||
|
return reply.send({
|
||||||
|
threatsBlockedToday: 0,
|
||||||
|
urlsCheckedToday: 0,
|
||||||
|
lastSyncAt: new Date().toISOString(),
|
||||||
|
syncDate: today,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Stats retrieval failed';
|
||||||
|
return reply.code(500).send({ error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post('/exposures/check', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||||
|
const userId = authReq.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return reply.code(401).send({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = request.body as { domain: string };
|
||||||
|
if (!body.domain) {
|
||||||
|
return reply.code(400).send({ error: 'domain is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { prisma } = await import('@shieldai/db');
|
||||||
|
|
||||||
|
const exposures = await prisma.exposure.findMany({
|
||||||
|
where: {
|
||||||
|
alert: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
dataSource: true,
|
||||||
|
breachName: true,
|
||||||
|
metadata: true,
|
||||||
|
},
|
||||||
|
take: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const domainLower = body.domain.toLowerCase();
|
||||||
|
const relevantExposures = exposures.filter((e) => {
|
||||||
|
const meta = e.metadata as Record<string, unknown> | null;
|
||||||
|
return meta?.domain?.toLowerCase() === domainLower ||
|
||||||
|
String(e.breachName).toLowerCase().includes(domainLower);
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
exposed: relevantExposures.length > 0,
|
||||||
|
sources: relevantExposures.map((e) => e.dataSource),
|
||||||
|
count: relevantExposures.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Exposure check failed';
|
||||||
|
return reply.code(500).send({ error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateExtensionToken(
|
||||||
|
token: string,
|
||||||
|
fastify: FastifyInstance
|
||||||
|
): Promise<{ userId: string; tier: string }> {
|
||||||
|
try {
|
||||||
|
const { prisma } = await import('@shieldai/db');
|
||||||
|
|
||||||
|
const session = await prisma.session.findFirst({
|
||||||
|
where: { token },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
include: {
|
||||||
|
subscription: {
|
||||||
|
where: { status: 'active' },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('Session not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tier = session.user.subscription[0]?.tier || 'basic';
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: session.userId,
|
||||||
|
tier: tier.toLowerCase(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Session not found') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
fastify.log.warn({ error }, 'Extension token validation failed');
|
||||||
|
throw new Error('Token validation failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { authMiddleware, AuthRequest } from './auth.middleware';
|
|||||||
import { voiceprintRoutes } from './voiceprint.routes';
|
import { voiceprintRoutes } from './voiceprint.routes';
|
||||||
import { spamshieldRoutes } from './spamshield.routes';
|
import { spamshieldRoutes } from './spamshield.routes';
|
||||||
import { darkwatchRoutes } from './darkwatch.routes';
|
import { darkwatchRoutes } from './darkwatch.routes';
|
||||||
|
import { reportRoutes } from './report.routes';
|
||||||
|
|
||||||
export async function routes(fastify: FastifyInstance) {
|
export async function routes(fastify: FastifyInstance) {
|
||||||
// Authenticated routes group
|
// Authenticated routes group
|
||||||
@@ -139,4 +140,12 @@ export async function routes(fastify: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
{ prefix: '/darkwatch' }
|
{ prefix: '/darkwatch' }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Report routes
|
||||||
|
fastify.register(
|
||||||
|
async (reportRouter) => {
|
||||||
|
await reportRoutes(reportRouter);
|
||||||
|
},
|
||||||
|
{ prefix: '/reports' }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
172
packages/api/src/routes/report.routes.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { reportService } from '@shieldai/report';
|
||||||
|
import { prisma } from '@shieldai/db';
|
||||||
|
import { ReportType, ReportStatus, ReportDataPayload } from '@shieldai/types';
|
||||||
|
|
||||||
|
interface AuthRequest extends FastifyRequest {
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
email?: string;
|
||||||
|
role?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reportRoutes(fastify: FastifyInstance) {
|
||||||
|
// Generate a new report
|
||||||
|
fastify.post('/generate', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const authReq = request as AuthRequest;
|
||||||
|
const userId = authReq.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
return reply.code(401).send({ error: 'User ID required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = request.body as {
|
||||||
|
reportType?: ReportType;
|
||||||
|
periodStart?: string;
|
||||||
|
periodEnd?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscription = await prisma.subscription.findFirst({
|
||||||
|
where: { userId, status: 'active' },
|
||||||
|
select: { id: true, tier: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return reply.code(404).send({ error: 'Active subscription not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportType = body.reportType || (subscription.tier === 'premium' ? 'ANNUAL_PREMIUM' : 'MONTHLY_PLUS');
|
||||||
|
|
||||||
|
const periodStart = body.periodStart ? new Date(body.periodStart) : undefined;
|
||||||
|
const periodEnd = body.periodEnd ? new Date(body.periodEnd) : undefined;
|
||||||
|
|
||||||
|
const report = await reportService.generateReport({
|
||||||
|
userId,
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
reportType,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(201).send(report);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get report history
|
||||||
|
fastify.get('/', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const authReq = request as AuthRequest;
|
||||||
|
const userId = authReq.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
return reply.code(401).send({ error: 'User ID required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = request.query as Record<string, string>;
|
||||||
|
const limit = parseInt(query.limit || '20', 10);
|
||||||
|
const offset = parseInt(query.offset || '0', 10);
|
||||||
|
|
||||||
|
const reports = await reportService.getReportHistory(userId, limit, offset);
|
||||||
|
return reply.code(200).send({ reports, count: reports.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get specific report
|
||||||
|
fastify.get('/:reportId', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const authReq = request as AuthRequest;
|
||||||
|
const userId = authReq.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
return reply.code(401).send({ error: 'User ID required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportId = (request.params as { reportId: string }).reportId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const report = await reportService.getReportById(userId, reportId);
|
||||||
|
return reply.code(200).send(report);
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code(404).send({ error: error instanceof Error ? error.message : 'Report not found' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get report HTML content
|
||||||
|
fastify.get('/:reportId/html', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const authReq = request as AuthRequest;
|
||||||
|
const userId = authReq.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
return reply.code(401).send({ error: 'User ID required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportId = (request.params as { reportId: string }).reportId;
|
||||||
|
|
||||||
|
const report = await prisma.securityReport.findFirst({
|
||||||
|
where: { id: reportId, userId },
|
||||||
|
select: { htmlContent: true, status: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
return reply.code(404).send({ error: 'Report not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.status !== 'COMPLETED') {
|
||||||
|
return reply.code(404).send({ error: 'Report not yet completed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.header('Content-Type', 'text/html');
|
||||||
|
return reply.code(200).send(report.htmlContent || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get report PDF
|
||||||
|
fastify.get('/:reportId/pdf', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const authReq = request as AuthRequest;
|
||||||
|
const userId = authReq.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
return reply.code(401).send({ error: 'User ID required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportId = (request.params as { reportId: string }).reportId;
|
||||||
|
|
||||||
|
const report = await prisma.securityReport.findFirst({
|
||||||
|
where: { id: reportId, userId },
|
||||||
|
select: { dataPayload: true, title: true, status: true, htmlContent: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
return reply.code(404).send({ error: 'Report not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.status !== 'COMPLETED') {
|
||||||
|
return reply.code(404).send({ error: 'Report not yet completed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pdfGenerator } = await import('@shieldai/report');
|
||||||
|
const pdfData = report.dataPayload
|
||||||
|
? (typeof report.dataPayload === 'string' ? JSON.parse(report.dataPayload) : report.dataPayload as unknown as ReportDataPayload)
|
||||||
|
: {
|
||||||
|
exposureSummary: { totalExposures: 0, newExposures: 0, resolvedExposures: 0, criticalExposures: 0, warningExposures: 0, infoExposures: 0, exposuresBySource: {} },
|
||||||
|
spamStats: { callsBlocked: 0, textsBlocked: 0, callsFlagged: 0, textsFlagged: 0, falsePositives: 0, totalSpamEvents: 0 },
|
||||||
|
voiceStats: { analysesRun: 0, threatsDetected: 0, enrollmentsActive: 0, syntheticDetections: 0, voiceMismatchEvents: 0 },
|
||||||
|
recommendations: [],
|
||||||
|
protectionScore: 0,
|
||||||
|
};
|
||||||
|
const pdfBuffer = await pdfGenerator.generate({
|
||||||
|
reportTitle: report.title,
|
||||||
|
periodStart: '',
|
||||||
|
periodEnd: '',
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
data: pdfData,
|
||||||
|
reportId,
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.header('Content-Type', 'application/pdf');
|
||||||
|
reply.header('Content-Disposition', `inline; filename="${report.title}.pdf"`);
|
||||||
|
return reply.code(200).send(pdfBuffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schedule pending reports (admin/scheduler endpoint)
|
||||||
|
fastify.post('/schedule/monthly', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const createdIds = await reportService.scheduleMonthlyReports();
|
||||||
|
return reply.code(200).send({ scheduled: createdIds.length, reportIds: createdIds });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post('/schedule/annual', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const createdIds = await reportService.scheduleAnnualReports();
|
||||||
|
return reply.code(200).send({ scheduled: createdIds.length, reportIds: createdIds });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,36 +1,65 @@
|
|||||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import fastifyMultipart from '@fastify/multipart';
|
||||||
import {
|
import {
|
||||||
voiceEnrollmentService,
|
voiceEnrollmentService,
|
||||||
analysisService,
|
analysisService,
|
||||||
batchAnalysisService,
|
batchAnalysisService,
|
||||||
voicePrintEnv,
|
voicePrintEnv,
|
||||||
AnalysisJobStatus,
|
|
||||||
} from '../services/voiceprint';
|
} from '../services/voiceprint';
|
||||||
|
|
||||||
|
interface AuthenticatedRequest extends FastifyRequest {
|
||||||
|
user?: { id: string; email: string; role: string };
|
||||||
|
authType?: 'jwt' | 'api-key' | 'anonymous';
|
||||||
|
}
|
||||||
|
|
||||||
export async function voiceprintRoutes(fastify: FastifyInstance) {
|
export async function voiceprintRoutes(fastify: FastifyInstance) {
|
||||||
|
// P1-2 fix: Require authentication on all VoicePrint routes
|
||||||
|
fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const authReq = request as AuthenticatedRequest;
|
||||||
|
if (authReq.authType === 'anonymous' || !authReq.user?.id || authReq.user.id === 'anonymous') {
|
||||||
|
return reply.code(401).send({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// P1-3 fix: Register multipart for audio file uploads
|
||||||
|
await fastify.register(fastifyMultipart, {
|
||||||
|
limits: {
|
||||||
|
fileSize: voicePrintEnv.ENROLLMENT_MAX_DURATION_SEC > 0
|
||||||
|
? 50 * 1024 * 1024 // 50MB max file size for audio
|
||||||
|
: 50 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
});
|
||||||
// Enroll a new voice profile
|
// Enroll a new voice profile
|
||||||
fastify.post('/enroll', async (request: FastifyRequest, reply: FastifyReply) => {
|
fastify.post('/enroll', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
const authReq = request as AuthenticatedRequest;
|
||||||
const userId = authReq.user?.id;
|
const userId = authReq.user?.id;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return reply.code(401).send({ error: 'User ID required' });
|
return reply.code(401).send({ error: 'User ID required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = request.body as {
|
// P1-3 fix: Parse multipart form-data for audio upload
|
||||||
name: string;
|
let name: string | undefined;
|
||||||
audio: Buffer;
|
let audioBuffer: Buffer | undefined;
|
||||||
};
|
|
||||||
|
|
||||||
if (!body.name || !body.audio) {
|
for await (const part of request.files()) {
|
||||||
return reply.code(400).send({ error: 'name and audio are required' });
|
if (part.type === 'file') {
|
||||||
|
audioBuffer = await part.toBuffer();
|
||||||
|
name = name || part.filename || 'voice_enrollment';
|
||||||
|
} else if (part.fieldname === 'name') {
|
||||||
|
name = part.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!audioBuffer || audioBuffer.length === 0) {
|
||||||
|
return reply.code(400).send({ error: 'audio file is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const enrollment = await voiceEnrollmentService.enroll(
|
const enrollment = await voiceEnrollmentService.enroll(
|
||||||
userId,
|
userId,
|
||||||
body.name,
|
name || 'voice_enrollment',
|
||||||
body.audio
|
audioBuffer
|
||||||
);
|
);
|
||||||
return reply.code(201).send({
|
return reply.code(201).send({
|
||||||
enrollment: {
|
enrollment: {
|
||||||
@@ -48,7 +77,7 @@ export async function voiceprintRoutes(fastify: FastifyInstance) {
|
|||||||
|
|
||||||
// List user's voice enrollments
|
// List user's voice enrollments
|
||||||
fastify.get('/enrollments', async (request: FastifyRequest, reply: FastifyReply) => {
|
fastify.get('/enrollments', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
const authReq = request as AuthenticatedRequest;
|
||||||
const userId = authReq.user?.id;
|
const userId = authReq.user?.id;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@@ -79,7 +108,7 @@ export async function voiceprintRoutes(fastify: FastifyInstance) {
|
|||||||
|
|
||||||
// Remove an enrollment
|
// Remove an enrollment
|
||||||
fastify.delete('/enrollments/:id', async (request: FastifyRequest, reply: FastifyReply) => {
|
fastify.delete('/enrollments/:id', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
const authReq = request as AuthenticatedRequest;
|
||||||
const userId = authReq.user?.id;
|
const userId = authReq.user?.id;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@@ -108,27 +137,36 @@ export async function voiceprintRoutes(fastify: FastifyInstance) {
|
|||||||
|
|
||||||
// Analyze a single audio file
|
// Analyze a single audio file
|
||||||
fastify.post('/analyze', async (request: FastifyRequest, reply: FastifyReply) => {
|
fastify.post('/analyze', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
const authReq = request as AuthenticatedRequest;
|
||||||
const userId = authReq.user?.id;
|
const userId = authReq.user?.id;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return reply.code(401).send({ error: 'User ID required' });
|
return reply.code(401).send({ error: 'User ID required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = request.body as {
|
// P1-3 fix: Parse multipart form-data for audio upload
|
||||||
audio: Buffer;
|
let audioBuffer: Buffer | undefined;
|
||||||
enrollmentId?: string;
|
let enrollmentId: string | undefined;
|
||||||
audioUrl?: string;
|
let audioUrl: string | undefined;
|
||||||
};
|
|
||||||
|
|
||||||
if (!body.audio) {
|
for await (const part of request.files()) {
|
||||||
return reply.code(400).send({ error: 'audio is required' });
|
if (part.type === 'file') {
|
||||||
|
audioBuffer = await part.toBuffer();
|
||||||
|
} else if (part.fieldname === 'enrollmentId') {
|
||||||
|
enrollmentId = part.value;
|
||||||
|
} else if (part.fieldname === 'audioUrl') {
|
||||||
|
audioUrl = part.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!audioBuffer || audioBuffer.length === 0) {
|
||||||
|
return reply.code(400).send({ error: 'audio file is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await analysisService.analyze(userId, body.audio, {
|
const result = await analysisService.analyze(userId, audioBuffer, {
|
||||||
enrollmentId: body.enrollmentId,
|
enrollmentId,
|
||||||
audioUrl: body.audioUrl,
|
audioUrl,
|
||||||
});
|
});
|
||||||
return reply.code(201).send({
|
return reply.code(201).send({
|
||||||
analysis: {
|
analysis: {
|
||||||
@@ -147,7 +185,7 @@ export async function voiceprintRoutes(fastify: FastifyInstance) {
|
|||||||
|
|
||||||
// Get analysis result by ID
|
// Get analysis result by ID
|
||||||
fastify.get('/results/:id', async (request: FastifyRequest, reply: FastifyReply) => {
|
fastify.get('/results/:id', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
const authReq = request as AuthenticatedRequest;
|
||||||
const userId = authReq.user?.id;
|
const userId = authReq.user?.id;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@@ -174,7 +212,7 @@ export async function voiceprintRoutes(fastify: FastifyInstance) {
|
|||||||
|
|
||||||
// Get analysis history
|
// Get analysis history
|
||||||
fastify.get('/history', async (request: FastifyRequest, reply: FastifyReply) => {
|
fastify.get('/history', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
const authReq = request as AuthenticatedRequest;
|
||||||
const userId = authReq.user?.id;
|
const userId = authReq.user?.id;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@@ -207,37 +245,42 @@ export async function voiceprintRoutes(fastify: FastifyInstance) {
|
|||||||
|
|
||||||
// Batch analyze multiple audio files
|
// Batch analyze multiple audio files
|
||||||
fastify.post('/batch', async (request: FastifyRequest, reply: FastifyReply) => {
|
fastify.post('/batch', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
const authReq = request as AuthenticatedRequest;
|
||||||
const userId = authReq.user?.id;
|
const userId = authReq.user?.id;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return reply.code(401).send({ error: 'User ID required' });
|
return reply.code(401).send({ error: 'User ID required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = request.body as {
|
// P1-3 fix: Parse multipart form-data for multiple audio uploads
|
||||||
files: Array<{
|
const files: Array<{ name: string; buffer: Buffer; audioUrl?: string }> = [];
|
||||||
name: string;
|
let enrollmentId: string | undefined;
|
||||||
audio: Buffer;
|
|
||||||
audioUrl?: string;
|
|
||||||
}>;
|
|
||||||
enrollmentId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!body.files || body.files.length === 0) {
|
for await (const part of request.files()) {
|
||||||
return reply.code(400).send({ error: 'files array is required' });
|
if (part.type === 'file') {
|
||||||
|
const buffer = await part.toBuffer();
|
||||||
|
files.push({
|
||||||
|
name: part.filename || `file_${files.length}`,
|
||||||
|
buffer,
|
||||||
|
});
|
||||||
|
} else if (part.fieldname === 'enrollmentId') {
|
||||||
|
enrollmentId = part.value;
|
||||||
|
} else if (part.fieldname === 'audioUrl') {
|
||||||
|
if (files.length > 0) {
|
||||||
|
files[files.length - 1].audioUrl = part.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return reply.code(400).send({ error: 'at least one audio file is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await batchAnalysisService.analyzeBatch(
|
const result = await batchAnalysisService.analyzeBatch(
|
||||||
userId,
|
userId,
|
||||||
body.files.map((f) => ({
|
files,
|
||||||
name: f.name,
|
{ enrollmentId }
|
||||||
buffer: f.audio,
|
|
||||||
audioUrl: f.audioUrl,
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
enrollmentId: body.enrollmentId,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return reply.code(201).send({
|
return reply.code(201).send({
|
||||||
|
|||||||
116
packages/api/src/routes/waitlist.routes.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { prisma } from '@shieldai/db';
|
||||||
|
import { EmailService } from '@shieldai/shared-notifications';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { Redis } from 'ioredis';
|
||||||
|
|
||||||
|
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
|
||||||
|
const connection = new Redis(redisUrl);
|
||||||
|
const waitlistEmailQueue = new Queue('waitlist-emails', { connection });
|
||||||
|
|
||||||
|
interface WaitlistSignupBody {
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
tier?: string;
|
||||||
|
utmSource?: string;
|
||||||
|
utmMedium?: string;
|
||||||
|
utmCampaign?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPosition(entryId: string): string {
|
||||||
|
const hash = entryId.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
||||||
|
return String(10000 + (hash % 90000));
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export async function waitlistRoutes(fastify: FastifyInstance) {
|
||||||
|
fastify.post('/waitlist/signup', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const body = request.body as WaitlistSignupBody;
|
||||||
|
|
||||||
|
if (!body.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
|
||||||
|
return reply.code(400).send({ error: 'Valid email is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = body.email.toLowerCase().trim();
|
||||||
|
|
||||||
|
const existing = await prisma.waitlistEntry.findFirst({
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return reply.code(200).send({
|
||||||
|
message: 'Already on the waitlist',
|
||||||
|
id: existing.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const validTiers = ['basic', 'plus', 'premium'] as const;
|
||||||
|
const tier = validTiers.includes(body.tier as typeof validTiers[number])
|
||||||
|
? (body.tier as string)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const entry = await prisma.waitlistEntry.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
name: body.name?.trim() || null,
|
||||||
|
source: 'landing_page',
|
||||||
|
tier: tier as any || null,
|
||||||
|
utmSource: body.utmSource || null,
|
||||||
|
utmMedium: body.utmMedium || null,
|
||||||
|
utmCampaign: body.utmCampaign || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const name = body.name?.trim() || 'there';
|
||||||
|
const position = getPosition(entry.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emailService = EmailService.getInstance();
|
||||||
|
const result = await emailService.sendWithTemplate(email, {
|
||||||
|
templateId: 'waitlist_confirmation',
|
||||||
|
variables: { name, position },
|
||||||
|
});
|
||||||
|
if (result.status === 'failed') {
|
||||||
|
request.log.warn({ error: result.error }, 'Failed to send waitlist confirmation email');
|
||||||
|
} else {
|
||||||
|
request.log.info({ email }, 'Waitlist confirmation email sent');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error({ err }, 'Error sending waitlist confirmation email');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
waitlistEmailQueue.add(
|
||||||
|
'send-waitlist-intro',
|
||||||
|
{ email, name, entryId: entry.id, tier },
|
||||||
|
{ delay: 1 * DAY_MS, attempts: 3, backoff: { type: 'exponential', delay: 5000 } }
|
||||||
|
),
|
||||||
|
waitlistEmailQueue.add(
|
||||||
|
'send-waitlist-features',
|
||||||
|
{ email, name, entryId: entry.id, tier },
|
||||||
|
{ delay: 3 * DAY_MS, attempts: 3, backoff: { type: 'exponential', delay: 5000 } }
|
||||||
|
),
|
||||||
|
waitlistEmailQueue.add(
|
||||||
|
'send-waitlist-launch-teaser',
|
||||||
|
{ email, name, entryId: entry.id, tier },
|
||||||
|
{ delay: 7 * DAY_MS, attempts: 3, backoff: { type: 'exponential', delay: 5000 } }
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
request.log.info({ email }, 'Welcome sequence scheduled');
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error({ err }, 'Failed to schedule welcome sequence emails');
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(201).send({
|
||||||
|
message: 'Welcome to the ShieldAI waitlist',
|
||||||
|
id: entry.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get('/waitlist/count', async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const count = await prisma.waitlistEntry.count();
|
||||||
|
return reply.send({ count });
|
||||||
|
});
|
||||||
|
}
|
||||||
112
packages/api/src/seed.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { prisma } from '@shieldai/db';
|
||||||
|
|
||||||
|
const blogPosts = [
|
||||||
|
{
|
||||||
|
slug: 'what-is-ai-voice-cloning',
|
||||||
|
title: 'What Is AI Voice Cloning and How to Protect Your Family',
|
||||||
|
excerpt: 'AI voice cloning technology is advancing rapidly. Learn how scammers use it to impersonate loved ones and how ShieldAI detects these attacks in real time.',
|
||||||
|
content: `<h2>Understanding AI Voice Cloning</h2>
|
||||||
|
<p>AI voice cloning uses deep learning models to analyze a small sample of someone's voice—sometimes just a few seconds from a social media video or phone call—and generate new speech that sounds identical to the original speaker.</p>
|
||||||
|
|
||||||
|
<h2>How Scammers Exploit It</h2>
|
||||||
|
<p>The most common attack pattern involves a scammer calling a victim while using a cloned voice of a family member. The fake "family member" claims to be in distress—needing bail money, hospital fees, or help with a car accident. The emotional urgency makes victims less likely to question the call's authenticity.</p>
|
||||||
|
|
||||||
|
<h2>Warning Signs</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Unexpected calls from family members asking for money</li>
|
||||||
|
<li>Slight delays or unnatural pauses in speech</li>
|
||||||
|
<li>Background noise that doesn't match the claimed location</li>
|
||||||
|
<li>Requests to keep the call secret or avoid contacting other family members</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>How ShieldAI Protects You</h2>
|
||||||
|
<p>ShieldAI's VoicePrint technology creates audio fingerprints for each family member's voice. When an incoming call is detected, our AI analyzes the audio in real time and flags any call that doesn't match the verified voiceprint. You'll receive an instant alert if a voice clone is suspected.</p>`,
|
||||||
|
authorName: 'ShieldAI Team',
|
||||||
|
tags: ['voice cloning', 'AI scams', 'family protection'],
|
||||||
|
published: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'dark-web-monitoring-guide',
|
||||||
|
title: 'Dark Web Monitoring: What Gets Exposed and How to Stay Safe',
|
||||||
|
excerpt: 'Your personal data is traded on dark web marketplaces every day. Here is what criminals buy, how they use it, and how ShieldAI monitors for your exposure.',
|
||||||
|
content: `<h2>What Is the Dark Web?</h2>
|
||||||
|
<p>The dark web is a hidden part of the internet accessible only through specialized browsers like Tor. While it has legitimate uses for privacy and journalism, it is also the primary marketplace for stolen data, including emails, passwords, phone numbers, and Social Security numbers.</p>
|
||||||
|
|
||||||
|
<h2>What Data Gets Exposed</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Email addresses</strong> — used for phishing and credential stuffing attacks</li>
|
||||||
|
<li><strong>Phone numbers</strong> — sold to robocallers and used for SIM swapping</li>
|
||||||
|
<li><strong>Passwords</strong> — sold in bulk for account takeover attempts</li>
|
||||||
|
<li><strong>Social Security Numbers</strong> — used for identity theft and tax fraud</li>
|
||||||
|
<li><strong>Home addresses</strong> — used for physical threats and doxxing</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>How ShieldAI Monitors for You</h2>
|
||||||
|
<p>ShieldAI continuously scans dark web marketplaces, forums, and known data leak repositories. When your monitored data appears in a new leak, we send you an immediate alert with details about what was exposed and recommended next steps.</p>
|
||||||
|
|
||||||
|
<h2>What to Do If Your Data Is Leaked</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Change passwords immediately — use unique passwords for each service</li>
|
||||||
|
<li>Enable two-factor authentication everywhere</li>
|
||||||
|
<li>Freeze your credit if SSN was exposed</li>
|
||||||
|
<li>Monitor bank and credit card statements for unusual activity</li>
|
||||||
|
<li>Run a ShieldAI dark web scan to check for additional exposures</li>
|
||||||
|
</ol>`,
|
||||||
|
authorName: 'ShieldAI Team',
|
||||||
|
tags: ['dark web', 'data breach', 'identity theft'],
|
||||||
|
published: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'spam-call-statistics-2025',
|
||||||
|
title: 'Spam Call Statistics 2025: The Rise of AI-Powered Phone Scams',
|
||||||
|
excerpt: 'Spam calls are at an all-time high, and AI is making them harder to detect. Here are the latest numbers and what you can do to protect yourself.',
|
||||||
|
content: `<h2>The Scale of the Problem</h2>
|
||||||
|
<p>In 2025, Americans received an estimated 55 billion spam calls — an average of 15 calls per person per month. AI-powered scam calls now account for 40% of all phone fraud attempts, up from just 12% in 2023.</p>
|
||||||
|
|
||||||
|
<h2>Key Statistics</h2>
|
||||||
|
<ul>
|
||||||
|
<li>1 in 3 Americans report losing money to phone scams</li>
|
||||||
|
<li>Average loss per victim: $1,200</li>
|
||||||
|
<li>68% of scam calls now use AI-generated voices</li>
|
||||||
|
<li>Elderly individuals (65+) are 3x more likely to fall victim</li>
|
||||||
|
<li>Most common scam: fake tech support (32% of all reports)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Why Traditional Blocking Falls Short</h2>
|
||||||
|
<p>Traditional spam blockers rely on known phone number databases. But AI-powered scammers constantly rotate numbers, spoof caller IDs, and use voice cloning to bypass voice-based verification. ShieldAI's machine learning approach classifies calls based on behavioral patterns, not just number reputation — catching new scams that traditional methods miss.</p>`,
|
||||||
|
authorName: 'ShieldAI Team',
|
||||||
|
tags: ['spam calls', 'statistics', 'AI scams'],
|
||||||
|
published: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
console.log('Seeding blog posts...');
|
||||||
|
|
||||||
|
for (const post of blogPosts) {
|
||||||
|
const existing = await prisma.blogPost.findUnique({ where: { slug: post.slug } });
|
||||||
|
if (existing) {
|
||||||
|
console.log(` Skipping "${post.slug}" — already exists`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.blogPost.create({
|
||||||
|
data: {
|
||||||
|
...post,
|
||||||
|
publishedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(` Created "${post.slug}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Seed complete!');
|
||||||
|
}
|
||||||
|
|
||||||
|
seed()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('Seed failed:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -1,12 +1,23 @@
|
|||||||
|
// dd-trace must be initialized before any other module is loaded for auto-instrumentation
|
||||||
|
import '@shieldai/monitoring/datadog-init';
|
||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import cors from "@fastify/cors";
|
import cors from "@fastify/cors";
|
||||||
import helmet from "@fastify/helmet";
|
import helmet from "@fastify/helmet";
|
||||||
import sensible from "@fastify/sensible";
|
import sensible from "@fastify/sensible";
|
||||||
import { extractOrGenerateRequestId } from "@shieldai/types";
|
import { extractOrGenerateRequestId } from "@shieldai/types";
|
||||||
import { authMiddleware } from "./middleware/auth.middleware";
|
import { authMiddleware } from "./middleware/auth.middleware";
|
||||||
|
import { errorHandlingMiddleware } from "./middleware/error-handling.middleware";
|
||||||
|
import { loggingMiddleware } from "./middleware/logging.middleware";
|
||||||
|
import { monitoringMiddleware } from "./middleware/monitoring.middleware";
|
||||||
import { darkwatchRoutes } from "./routes/darkwatch.routes";
|
import { darkwatchRoutes } from "./routes/darkwatch.routes";
|
||||||
import { voiceprintRoutes } from "./routes/voiceprint.routes";
|
import { voiceprintRoutes } from "./routes/voiceprint.routes";
|
||||||
import { correlationRoutes } from "./routes/correlation.routes";
|
import { correlationRoutes } from "./routes/correlation.routes";
|
||||||
|
import { extensionRoutes } from "./routes/extension.routes";
|
||||||
|
import { waitlistRoutes } from "./routes/waitlist.routes";
|
||||||
|
import { blogRoutes } from "./routes/blog.routes";
|
||||||
|
import { blogAdminRoutes } from "./routes/blog-admin.routes";
|
||||||
|
import { captureSentryError } from "@shieldai/monitoring";
|
||||||
|
import { getCorsOrigins } from "./config/api.config";
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: {
|
logger: {
|
||||||
@@ -15,13 +26,23 @@ const app = Fastify({
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
await app.register(cors, { origin: process.env.CORS_ORIGIN || "http://localhost:5173" });
|
const corsOrigins = getCorsOrigins();
|
||||||
|
await app.register(cors, { origin: corsOrigins });
|
||||||
await app.register(helmet);
|
await app.register(helmet);
|
||||||
await app.register(sensible);
|
await app.register(sensible);
|
||||||
|
|
||||||
// Register auth middleware to populate request.user
|
// Register auth middleware to populate request.user
|
||||||
await app.register(authMiddleware);
|
await app.register(authMiddleware);
|
||||||
|
|
||||||
|
// Register logging middleware (request/response logging)
|
||||||
|
await app.register(loggingMiddleware);
|
||||||
|
|
||||||
|
// Register monitoring middleware (CloudWatch metrics)
|
||||||
|
await app.register(monitoringMiddleware);
|
||||||
|
|
||||||
|
// Register error handling middleware (Sentry integration)
|
||||||
|
await app.register(errorHandlingMiddleware);
|
||||||
|
|
||||||
app.addHook("onRequest", async (request, _reply) => {
|
app.addHook("onRequest", async (request, _reply) => {
|
||||||
const requestId = extractOrGenerateRequestId(request.headers);
|
const requestId = extractOrGenerateRequestId(request.headers);
|
||||||
request.id = requestId;
|
request.id = requestId;
|
||||||
@@ -34,6 +55,10 @@ async function bootstrap() {
|
|||||||
await app.register(darkwatchRoutes);
|
await app.register(darkwatchRoutes);
|
||||||
await app.register(voiceprintRoutes);
|
await app.register(voiceprintRoutes);
|
||||||
await app.register(correlationRoutes);
|
await app.register(correlationRoutes);
|
||||||
|
await app.register(extensionRoutes, { prefix: '/extension' });
|
||||||
|
await app.register(waitlistRoutes);
|
||||||
|
await app.register(blogRoutes, { prefix: '/blog' });
|
||||||
|
await app.register(blogAdminRoutes);
|
||||||
|
|
||||||
app.get("/health", async () => ({ status: "ok", timestamp: new Date().toISOString() }));
|
app.get("/health", async () => ({ status: "ok", timestamp: new Date().toISOString() }));
|
||||||
|
|
||||||
@@ -42,6 +67,7 @@ async function bootstrap() {
|
|||||||
app.log.info(`Server listening on port ${process.env.PORT || 3000}`);
|
app.log.info(`Server listening on port ${process.env.PORT || 3000}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
app.log.error(err);
|
app.log.error(err);
|
||||||
|
captureSentryError(err as Error, { context: "server_startup" });
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
192
packages/api/src/services/voiceprint/embedding.service.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { spawn } from "child_process";
|
||||||
|
import { logger } from './logger';
|
||||||
|
import { voicePrintEnv } from './voiceprint.config';
|
||||||
|
|
||||||
|
const EMBEDDING_DIM = 192;
|
||||||
|
const MODEL_VERSION = "ecapa-tdnn-0.1.0-mock";
|
||||||
|
|
||||||
|
export class EmbeddingService {
|
||||||
|
private mlServiceUrl: string;
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.mlServiceUrl = process.env.VOICEPRINT_ML_URL || "http://localhost:8001";
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
if (this.initialized) return;
|
||||||
|
this.initialized = true;
|
||||||
|
logger.info('Embedding service initialized', { mlUrl: this.mlServiceUrl, modelVersion: MODEL_VERSION });
|
||||||
|
}
|
||||||
|
|
||||||
|
async extract(audioBuffer: Buffer): Promise<number[]> {
|
||||||
|
await this.initialize();
|
||||||
|
|
||||||
|
const mlAvailable = await this.checkMLService();
|
||||||
|
if (mlAvailable) {
|
||||||
|
logger.info('Using ML service for embedding', { mlUrl: this.mlServiceUrl });
|
||||||
|
return this.extractViaML(audioBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Using mock embedding generation', { audioBufferLength: audioBuffer.length });
|
||||||
|
return this.generateMockFromBuffer(audioBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async analyze(audioBuffer: Buffer): Promise<{
|
||||||
|
confidence: number;
|
||||||
|
detectionType: string;
|
||||||
|
features: Record<string, number>;
|
||||||
|
embedding: number[];
|
||||||
|
}> {
|
||||||
|
const embedding = await this.extract(audioBuffer);
|
||||||
|
const confidence = this.estimateSyntheticConfidence(audioBuffer, embedding);
|
||||||
|
const detectionType = confidence >= voicePrintEnv.SYNTHETIC_THRESHOLD ? 'synthetic_voice' : 'natural';
|
||||||
|
const features = this.extractAnalysisFeatures(audioBuffer, embedding);
|
||||||
|
|
||||||
|
return { confidence, detectionType, features, embedding };
|
||||||
|
}
|
||||||
|
|
||||||
|
getModelVersion(): string {
|
||||||
|
return MODEL_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async extractViaML(audioBuffer: Buffer): Promise<number[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const jsonInput = audioBuffer.toString("base64");
|
||||||
|
const proc = spawn("python3", [
|
||||||
|
"-c",
|
||||||
|
`
|
||||||
|
import urllib.request, json, sys
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"${this.mlServiceUrl}/embedding",
|
||||||
|
data=json.dumps({"audio": "${jsonInput.substring(0, 5000)}"}).encode(),
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
sys.stdout.write(json.dumps({"ok": True, "vector": data.get("embedding", []), "dim": data.get("dimension", ${EMBEDDING_DIM})}))
|
||||||
|
except Exception as e:
|
||||||
|
sys.stdout.write(json.dumps({"ok": False, "error": str(e)}))
|
||||||
|
`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
let output = "";
|
||||||
|
proc.stdout.on("data", (chunk) => { output += chunk.toString(); });
|
||||||
|
proc.on("close", (code) => {
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(output);
|
||||||
|
if (result.ok && result.vector && result.vector.length === EMBEDDING_DIM) {
|
||||||
|
resolve(result.vector);
|
||||||
|
} else {
|
||||||
|
resolve(this.generateMockFromBuffer(audioBuffer));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
resolve(this.generateMockFromBuffer(audioBuffer));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateMockFromBuffer(audioBuffer: Buffer): number[] {
|
||||||
|
let hash = 0;
|
||||||
|
const sampleSize = Math.min(audioBuffer.length, 1024);
|
||||||
|
for (let i = 0; i < sampleSize; i += 4) {
|
||||||
|
hash = ((hash << 5) - hash + audioBuffer.readInt32LE(i)) | 0;
|
||||||
|
}
|
||||||
|
const seed = Math.abs(hash);
|
||||||
|
|
||||||
|
const rng = this.createRNG(seed);
|
||||||
|
const vector: number[] = [];
|
||||||
|
|
||||||
|
// Box-Muller transform for Gaussian distribution
|
||||||
|
for (let i = 0; i < EMBEDDING_DIM; i += 2) {
|
||||||
|
const u1 = rng();
|
||||||
|
const u2 = rng();
|
||||||
|
const mag = Math.sqrt(-2 * Math.log(u1));
|
||||||
|
const z0 = mag * Math.cos(2 * Math.PI * u2);
|
||||||
|
const z1 = mag * Math.sin(2 * Math.PI * u2);
|
||||||
|
vector.push(parseFloat(z0.toFixed(6)));
|
||||||
|
if (i + 1 < EMBEDDING_DIM) {
|
||||||
|
vector.push(parseFloat(z1.toFixed(6)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// L2 normalize
|
||||||
|
const norm = Math.sqrt(vector.reduce((s, v) => s + v * v, 0));
|
||||||
|
return vector.map((v) => parseFloat((v / norm).toFixed(6)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private estimateSyntheticConfidence(buffer: Buffer, embedding: number[]): number {
|
||||||
|
const meanAmplitude = buffer.reduce((s, v) => s + v, 0) / buffer.length / 255;
|
||||||
|
const meanEmbedding = embedding.reduce((s, v) => s + v, 0) / embedding.length;
|
||||||
|
const embeddingStdDev = Math.sqrt(embedding.reduce((s, v) => s + (v - meanEmbedding) ** 2, 0) / embedding.length);
|
||||||
|
|
||||||
|
const amplitudeScore = Math.abs(meanAmplitude - 0.5) * 2;
|
||||||
|
const embeddingScore = 1.0 - Math.min(1.0, embeddingStdDev * 2);
|
||||||
|
const varianceScore = Math.min(1.0, buffer.length / 10000);
|
||||||
|
|
||||||
|
return Math.min(1.0, amplitudeScore * 0.3 + embeddingScore * 0.4 + varianceScore * 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractAnalysisFeatures(buffer: Buffer, embedding: number[]): Record<string, number> {
|
||||||
|
const meanAmplitude = buffer.reduce((s, v) => s + v, 0) / buffer.length / 255;
|
||||||
|
const zeroCrossings = buffer.reduce((count, v, i, arr) => {
|
||||||
|
return i > 0 && ((v - 128) * (arr[i - 1] - 128) < 0) ? count + 1 : count;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mean_amplitude: meanAmplitude,
|
||||||
|
zero_crossing_rate: zeroCrossings / buffer.length,
|
||||||
|
embedding_energy: embedding.reduce((s, v) => s + v * v, 0),
|
||||||
|
embedding_entropy: this.calculateEntropy(embedding),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateEntropy(values: number[]): number {
|
||||||
|
const bins = 20;
|
||||||
|
const histogram = new Array(bins).fill(0);
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
const range = max - min || 1;
|
||||||
|
|
||||||
|
for (const v of values) {
|
||||||
|
const bin = Math.min(bins - 1, Math.floor(((v - min) / range) * bins));
|
||||||
|
histogram[bin]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entropy = 0;
|
||||||
|
const total = values.length;
|
||||||
|
for (const count of histogram) {
|
||||||
|
if (count > 0) {
|
||||||
|
const p = count / total;
|
||||||
|
entropy -= p * Math.log2(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entropy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkMLService(): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const proc = spawn("python3", [
|
||||||
|
"-c",
|
||||||
|
`
|
||||||
|
import urllib.request, sys
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen("${this.mlServiceUrl}/health", timeout=2)
|
||||||
|
sys.exit(0)
|
||||||
|
except:
|
||||||
|
sys.exit(1)
|
||||||
|
`,
|
||||||
|
]);
|
||||||
|
proc.on("close", (code) => resolve(code === 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private createRNG(seed: number): () => number {
|
||||||
|
return () => {
|
||||||
|
seed = (seed * 1664525 + 1013904223) & 0xffffffff;
|
||||||
|
return (seed >>> 0) / 0xffffffff;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
93
packages/api/src/services/voiceprint/faiss.index.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { logger } from './logger';
|
||||||
|
import { voicePrintEnv } from './voiceprint.config';
|
||||||
|
|
||||||
|
export class FAISSIndex {
|
||||||
|
private store: Map<string, number[]> = new Map();
|
||||||
|
private readonly indexPath: string;
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
|
constructor(path?: string) {
|
||||||
|
this.indexPath = path ?? voicePrintEnv.FAISS_INDEX_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
if (this.initialized) return;
|
||||||
|
await this.loadFromDatabase();
|
||||||
|
this.initialized = true;
|
||||||
|
logger.info('FAISS index initialized', { indexPath: this.indexPath, enrollmentCount: this.store.size });
|
||||||
|
}
|
||||||
|
|
||||||
|
async add(enrollmentId: string, embedding: number[]): Promise<void> {
|
||||||
|
await this.initialize();
|
||||||
|
const normalized = [...embedding];
|
||||||
|
this.normalizeInPlace(normalized);
|
||||||
|
this.store.set(enrollmentId, normalized);
|
||||||
|
logger.info('Added enrollment to FAISS index', { enrollmentId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(enrollmentId: string): Promise<void> {
|
||||||
|
await this.initialize();
|
||||||
|
this.store.delete(enrollmentId);
|
||||||
|
logger.info('Removed enrollment from FAISS index', { enrollmentId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(embedding: number[], topK: number = 5): Promise<Array<{ id: string; similarity: number }>> {
|
||||||
|
await this.initialize();
|
||||||
|
|
||||||
|
const normalized = [...embedding];
|
||||||
|
this.normalizeInPlace(normalized);
|
||||||
|
|
||||||
|
const scores: Array<{ id: string; similarity: number }> = [];
|
||||||
|
|
||||||
|
for (const [id, vector] of this.store.entries()) {
|
||||||
|
const similarity = this.cosineSimilarity(normalized, vector);
|
||||||
|
scores.push({ id, similarity });
|
||||||
|
}
|
||||||
|
|
||||||
|
scores.sort((a, b) => b.similarity - a.similarity);
|
||||||
|
return scores.slice(0, topK);
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(): Promise<void> {
|
||||||
|
await this.initialize();
|
||||||
|
logger.info('FAISS index saved', { indexPath: this.indexPath, count: this.store.size });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadFromDatabase(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { prisma } = await import('@shieldai/db');
|
||||||
|
const enrollments = await prisma.voiceEnrollment.findMany({
|
||||||
|
select: { id: true, voiceHash: true },
|
||||||
|
});
|
||||||
|
// Note: voiceHash is stored, not the actual embedding vector
|
||||||
|
// In production, we'd store the full embedding vector
|
||||||
|
logger.info('Loaded enrollments from database', { count: enrollments.length });
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to load enrollments from database', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cosineSimilarity(a: number[], b: number[]): number {
|
||||||
|
let dotProduct = 0;
|
||||||
|
let normA = 0;
|
||||||
|
let normB = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
dotProduct += a[i] * b[i];
|
||||||
|
normA += a[i] * a[i];
|
||||||
|
normB += b[i] * b[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
|
||||||
|
return denominator > 0 ? dotProduct / denominator : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeInPlace(vector: number[]): void {
|
||||||
|
const norm = Math.sqrt(vector.reduce((s, v) => s + v * v, 0));
|
||||||
|
if (norm > 0) {
|
||||||
|
for (let i = 0; i < vector.length; i++) {
|
||||||
|
vector[i] /= norm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,11 @@ export {
|
|||||||
audioPreprocessingConfig,
|
audioPreprocessingConfig,
|
||||||
voicePrintFeatureFlags,
|
voicePrintFeatureFlags,
|
||||||
voicePrintRateLimits,
|
voicePrintRateLimits,
|
||||||
checkFlag,
|
|
||||||
isFeatureEnabled,
|
|
||||||
} from './voiceprint.config';
|
} from './voiceprint.config';
|
||||||
|
|
||||||
|
// Feature flags
|
||||||
|
export { checkFlag, isFeatureEnabled } from './voiceprint.feature-flags';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
|
|||||||
36
packages/api/src/services/voiceprint/logger.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { FastifyLoggerOptions } from 'fastify';
|
||||||
|
|
||||||
|
export interface Logger {
|
||||||
|
info(message: string, context?: Record<string, unknown>): void;
|
||||||
|
warn(message: string, context?: Record<string, unknown>): void;
|
||||||
|
error(message: string, context?: Record<string, unknown>): void;
|
||||||
|
debug(message: string, context?: Record<string, unknown>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConsoleLogger implements Logger {
|
||||||
|
info(message: string, context?: Record<string, unknown>): void {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const logContext = context ? ` ${JSON.stringify(context)}` : '';
|
||||||
|
console.log(`[${timestamp}] [INFO] ${message}${logContext}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(message: string, context?: Record<string, unknown>): void {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const logContext = context ? ` ${JSON.stringify(context)}` : '';
|
||||||
|
console.warn(`[${timestamp}] [WARN] ${message}${logContext}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string, context?: Record<string, unknown>): void {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const logContext = context ? ` ${JSON.stringify(context)}` : '';
|
||||||
|
console.error(`[${timestamp}] [ERROR] ${message}${logContext}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(message: string, context?: Record<string, unknown>): void {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const logContext = context ? ` ${JSON.stringify(context)}` : '';
|
||||||
|
console.debug(`[${timestamp}] [DEBUG] ${message}${logContext}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = new ConsoleLogger();
|
||||||
@@ -1,22 +1,24 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
import { checkFlag } from './voiceprint.feature-flags';
|
import { checkFlag } from './voiceprint.feature-flags';
|
||||||
|
|
||||||
// Environment variables for VoicePrint
|
// P3-4 fix: Use strict() to catch typos in env var names
|
||||||
|
// P3-1 fix: Use safeParse() to avoid module-level crash on missing env vars
|
||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
ECAPA_TDNN_MODEL_PATH: z.string().default('./models/ecapa-tdnn'),
|
ECAPA_TDNN_MODEL_PATH: z.string().default('./models/ecapa-tdnn'),
|
||||||
ML_SERVICE_URL: z.string().default('http://localhost:8001'),
|
ML_SERVICE_URL: z.string().default('http://localhost:8001'),
|
||||||
FAISS_INDEX_PATH: z.string().default('./data/voiceprint_faiss.index'),
|
FAISS_INDEX_PATH: z.string().default('./data/voiceprint_faiss.index'),
|
||||||
AUDIO_STORAGE_BUCKET: z.string().default('voiceprint-audio'),
|
AUDIO_STORAGE_BUCKET: z.string().default('voiceprint-audio'),
|
||||||
AUDIO_STORAGE_ENDPOINT: z.string().default('http://localhost:9000'),
|
AUDIO_STORAGE_ENDPOINT: z.string().default('http://localhost:9000'),
|
||||||
SYNTHETIC_THRESHOLD: z.string().transform(Number).default(0.75),
|
SYNTHETIC_THRESHOLD: z.string().transform(Number).default('0.75'),
|
||||||
ENROLLMENT_MIN_DURATION_SEC: z.string().transform(Number).default(3),
|
ENROLLMENT_MIN_DURATION_SEC: z.string().transform(Number).default('3'),
|
||||||
ENROLLMENT_MAX_DURATION_SEC: z.string().transform(Number).default(60),
|
ENROLLMENT_MAX_DURATION_SEC: z.string().transform(Number).default('60'),
|
||||||
EMBEDDING_DIMENSIONS: z.string().transform(Number).default(192),
|
EMBEDDING_DIMENSIONS: z.string().transform(Number).default('192'),
|
||||||
BATCH_MAX_FILES: z.string().transform(Number).default(20),
|
BATCH_MAX_FILES: z.string().transform(Number).default('20'),
|
||||||
ANALYSIS_TIMEOUT_MS: z.string().transform(Number).default(30000),
|
ANALYSIS_TIMEOUT_MS: z.string().transform(Number).default('30000'),
|
||||||
});
|
}).strict();
|
||||||
|
|
||||||
export const voicePrintEnv = envSchema.parse({
|
const envInput = {
|
||||||
ECAPA_TDNN_MODEL_PATH: process.env.ECAPA_TDNN_MODEL_PATH,
|
ECAPA_TDNN_MODEL_PATH: process.env.ECAPA_TDNN_MODEL_PATH,
|
||||||
ML_SERVICE_URL: process.env.ML_SERVICE_URL,
|
ML_SERVICE_URL: process.env.ML_SERVICE_URL,
|
||||||
FAISS_INDEX_PATH: process.env.FAISS_INDEX_PATH,
|
FAISS_INDEX_PATH: process.env.FAISS_INDEX_PATH,
|
||||||
@@ -28,7 +30,23 @@ export const voicePrintEnv = envSchema.parse({
|
|||||||
EMBEDDING_DIMENSIONS: process.env.EMBEDDING_DIMENSIONS,
|
EMBEDDING_DIMENSIONS: process.env.EMBEDDING_DIMENSIONS,
|
||||||
BATCH_MAX_FILES: process.env.BATCH_MAX_FILES,
|
BATCH_MAX_FILES: process.env.BATCH_MAX_FILES,
|
||||||
ANALYSIS_TIMEOUT_MS: process.env.ANALYSIS_TIMEOUT_MS,
|
ANALYSIS_TIMEOUT_MS: process.env.ANALYSIS_TIMEOUT_MS,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const parsed = envSchema.safeParse(envInput);
|
||||||
|
export const voicePrintEnv = parsed.success
|
||||||
|
? parsed.data
|
||||||
|
: envSchema.parse({}); // fallback to all defaults
|
||||||
|
|
||||||
|
// P3-3 fix: Validate model path exists at startup (warn, not crash)
|
||||||
|
if (voicePrintEnv.ECAPA_TDNN_MODEL_PATH && !existsSync(voicePrintEnv.ECAPA_TDNN_MODEL_PATH)) {
|
||||||
|
console.warn(
|
||||||
|
`[VoicePrint] Model path not found: ${voicePrintEnv.ECAPA_TDNN_MODEL_PATH} (using mock model)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.warn('[VoicePrint] Env validation warnings:', parsed.error.issues.map((i: z.ZodIssue) => `${i.path.join('.')}: ${i.message}`).join(', '));
|
||||||
|
}
|
||||||
|
|
||||||
// Audio source types
|
// Audio source types
|
||||||
export enum VoicePrintSource {
|
export enum VoicePrintSource {
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
* Re-exports the checkFlag function from the centralized feature flag system
|
* Re-exports the checkFlag function from the centralized feature flag system
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Re-export the checkFlag function from the spamshield feature flags module
|
// Re-export the checkFlag and isFeatureEnabled functions from the spamshield feature flags module
|
||||||
export { checkFlag } from '../spamshield/feature-flags';
|
export { checkFlag, isFeatureEnabled } from '../spamshield/feature-flags';
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
|
import { createHash } from 'crypto';
|
||||||
import { prisma, VoiceEnrollment, VoiceAnalysis } from '@shieldai/db';
|
import { prisma, VoiceEnrollment, VoiceAnalysis } from '@shieldai/db';
|
||||||
import {
|
import {
|
||||||
voicePrintEnv,
|
voicePrintEnv,
|
||||||
AnalysisJobStatus,
|
AnalysisJobStatus,
|
||||||
DetectionType,
|
DetectionType,
|
||||||
ConfidenceLevel,
|
|
||||||
audioPreprocessingConfig,
|
audioPreprocessingConfig,
|
||||||
voicePrintFeatureFlags,
|
voicePrintFeatureFlags,
|
||||||
} from './voiceprint.config';
|
} from './voiceprint.config';
|
||||||
import { checkFlag } from './voiceprint.feature-flags';
|
import { checkFlag } from './voiceprint.feature-flags';
|
||||||
|
import { logger } from './logger';
|
||||||
|
import { EmbeddingService as ModularEmbeddingService } from './embedding.service';
|
||||||
|
import { FAISSIndex as ModularFAISSIndex } from './faiss.index';
|
||||||
|
|
||||||
|
// Alias for backwards compatibility
|
||||||
|
const EmbeddingService = ModularEmbeddingService;
|
||||||
|
const FAISSIndex = ModularFAISSIndex;
|
||||||
|
|
||||||
// Audio preprocessing service
|
// Audio preprocessing service
|
||||||
export class AudioPreprocessor {
|
export class AudioPreprocessor {
|
||||||
@@ -189,20 +196,19 @@ export class VoiceEnrollmentService {
|
|||||||
const enrollments = await prisma.voiceEnrollment.findMany({
|
const enrollments = await prisma.voiceEnrollment.findMany({
|
||||||
where: { id: { in: enrollmentIds } },
|
where: { id: { in: enrollmentIds } },
|
||||||
});
|
});
|
||||||
|
const enrollmentMap = new Map(enrollments.map((e) => [e.id, e]));
|
||||||
|
|
||||||
return results.map((r, i) => ({
|
return results
|
||||||
enrollment: enrollments[i],
|
.map((r) => ({
|
||||||
similarity: r.similarity,
|
enrollment: enrollmentMap.get(r.id),
|
||||||
}));
|
similarity: r.similarity,
|
||||||
|
}))
|
||||||
|
.filter((r): r is { enrollment: VoiceEnrollment; similarity: number } => r.enrollment !== undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
private computeEmbeddingHash(embedding: number[]): string {
|
private computeEmbeddingHash(embedding: number[]): string {
|
||||||
let hash = 0;
|
const content = embedding.map((v) => v.toFixed(6)).join(',');
|
||||||
for (let i = 0; i < embedding.length; i++) {
|
return `vp_${createHash('sha256').update(content).digest('hex').slice(0, 16)}_${embedding.length}`;
|
||||||
hash = ((hash << 5) - hash) + embedding[i];
|
|
||||||
hash |= 0;
|
|
||||||
}
|
|
||||||
return `vp_${Math.abs(hash).toString(16)}_${embedding.length}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,20 +293,17 @@ export class AnalysisService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private computeAudioHash(buffer: Buffer): string {
|
private computeAudioHash(buffer: Buffer): string {
|
||||||
let hash = 0;
|
return `audio_${createHash('sha256').update(buffer).digest('hex').slice(0, 16)}`;
|
||||||
const sampleSize = Math.min(buffer.length, 1024);
|
|
||||||
for (let i = 0; i < sampleSize; i += 8) {
|
|
||||||
hash = ((hash << 5) - hash) + buffer.readUInt8(i);
|
|
||||||
hash |= 0;
|
|
||||||
}
|
|
||||||
return `audio_${Math.abs(hash).toString(16)}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch analysis service
|
// Batch analysis service
|
||||||
export class BatchAnalysisService {
|
export class BatchAnalysisService {
|
||||||
|
private readonly maxConcurrency = 5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Analyze multiple audio files in a batch.
|
* Analyze multiple audio files in a batch with parallel processing.
|
||||||
|
* Uses Promise.allSettled with concurrency control for better performance.
|
||||||
*/
|
*/
|
||||||
async analyzeBatch(
|
async analyzeBatch(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -328,31 +331,70 @@ export class BatchAnalysisService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const jobId = `batch_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
logger.info('Starting batch analysis', {
|
||||||
|
jobId,
|
||||||
|
userId,
|
||||||
|
totalFiles: files.length,
|
||||||
|
enrollmentId: options?.enrollmentId
|
||||||
|
});
|
||||||
|
|
||||||
const analysisService = new AnalysisService();
|
const analysisService = new AnalysisService();
|
||||||
const results: VoiceAnalysis[] = [];
|
const results: VoiceAnalysis[] = [];
|
||||||
|
const errors: Array<{ name: string; error: string }> = [];
|
||||||
let synthetic = 0;
|
let synthetic = 0;
|
||||||
let natural = 0;
|
let natural = 0;
|
||||||
let failed = 0;
|
|
||||||
|
|
||||||
for (const file of files) {
|
// Process with concurrency control using chunked Promise.allSettled
|
||||||
try {
|
const processChunk = async (chunk: typeof files) => {
|
||||||
const result = await analysisService.analyze(userId, file.buffer, {
|
const promises = chunk.map(async (file) => {
|
||||||
enrollmentId: options?.enrollmentId,
|
try {
|
||||||
audioUrl: file.audioUrl,
|
const result = await analysisService.analyze(userId, file.buffer, {
|
||||||
});
|
enrollmentId: options?.enrollmentId,
|
||||||
results.push(result);
|
audioUrl: file.audioUrl,
|
||||||
if (result.isSynthetic) {
|
});
|
||||||
synthetic++;
|
return { success: true as const, result, name: file.name };
|
||||||
} else {
|
} catch (error) {
|
||||||
natural++;
|
const message = error instanceof Error ? error.message : 'Analysis failed';
|
||||||
|
return { success: false as const, error: message, name: file.name };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const outcomes = await Promise.allSettled(promises);
|
||||||
|
|
||||||
|
for (const outcome of outcomes) {
|
||||||
|
if (outcome.status === 'fulfilled') {
|
||||||
|
if (outcome.value.success && outcome.value.result) {
|
||||||
|
results.push(outcome.value.result);
|
||||||
|
if (outcome.value.result.isSynthetic) {
|
||||||
|
synthetic++;
|
||||||
|
} else {
|
||||||
|
natural++;
|
||||||
|
}
|
||||||
|
} else if (!outcome.value.success) {
|
||||||
|
errors.push({ name: outcome.value.name, error: outcome.value.error });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error(`Batch analysis failed for ${file.name}:`, error);
|
|
||||||
failed++;
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process files in chunks for concurrency control
|
||||||
|
for (let i = 0; i < files.length; i += this.maxConcurrency) {
|
||||||
|
const chunk = files.slice(i, i + this.maxConcurrency);
|
||||||
|
await processChunk(chunk);
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobId = `batch_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
const failed = errors.length;
|
||||||
|
|
||||||
|
// TODO: P3-2 - Persist batch jobId to database once schema is fixed
|
||||||
|
// Schema errors need to be resolved first (AnalysisJob relation issues)
|
||||||
|
logger.info('Batch analysis completed', {
|
||||||
|
jobId,
|
||||||
|
successfulResults: results.length,
|
||||||
|
failedCount: failed,
|
||||||
|
synthetic,
|
||||||
|
natural
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
jobId,
|
jobId,
|
||||||
@@ -367,226 +409,11 @@ export class BatchAnalysisService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embedding service — ECAPA-TDNN inference wrapper
|
// Re-export improved modular implementations
|
||||||
export class EmbeddingService {
|
export { EmbeddingService } from './embedding.service';
|
||||||
private initialized = false;
|
export { FAISSIndex } from './faiss.index';
|
||||||
|
|
||||||
/**
|
// Export singleton instances for backwards compatibility
|
||||||
* Initialize the ECAPA-TDNN model.
|
|
||||||
*/
|
|
||||||
async initialize(): Promise<void> {
|
|
||||||
if (this.initialized) return;
|
|
||||||
|
|
||||||
// TODO: Connect to Python ML service for real inference
|
|
||||||
// const response = await fetch(`${voicePrintEnv.ML_SERVICE_URL}/initialize`, {
|
|
||||||
// method: 'POST',
|
|
||||||
// body: JSON.stringify({ modelPath: voicePrintEnv.ECAPA_TDNN_MODEL_PATH }),
|
|
||||||
// });
|
|
||||||
|
|
||||||
this.initialized = true;
|
|
||||||
console.log('Embedding service initialized (mock model)');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract voice embedding from audio.
|
|
||||||
*/
|
|
||||||
async extract(audioBuffer: Buffer): Promise<number[]> {
|
|
||||||
await this.initialize();
|
|
||||||
|
|
||||||
// TODO: Call Python ML service
|
|
||||||
// const response = await fetch(`${voicePrintEnv.ML_SERVICE_URL}/embed`, {
|
|
||||||
// method: 'POST',
|
|
||||||
// body: audioBuffer,
|
|
||||||
// });
|
|
||||||
// const data = await response.json();
|
|
||||||
// return data.embedding;
|
|
||||||
|
|
||||||
// Mock: generate deterministic embedding based on buffer content
|
|
||||||
const dims = voicePrintEnv.EMBEDDING_DIMENSIONS;
|
|
||||||
const embedding: number[] = new Array(dims);
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < Math.min(audioBuffer.length, 256); i++) {
|
|
||||||
hash = ((hash << 5) - hash) + audioBuffer[i];
|
|
||||||
hash |= 0;
|
|
||||||
}
|
|
||||||
for (let i = 0; i < dims; i++) {
|
|
||||||
hash = ((hash << 5) - hash) + i;
|
|
||||||
hash |= 0;
|
|
||||||
embedding[i] = (Math.abs(hash) % 1000) / 1000.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// L2 normalize
|
|
||||||
const norm = Math.sqrt(embedding.reduce((s, v) => s + v * v, 0));
|
|
||||||
return embedding.map((v) => v / norm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run full analysis: embedding + synthetic detection.
|
|
||||||
*/
|
|
||||||
async analyze(audioBuffer: Buffer): Promise<{
|
|
||||||
confidence: number;
|
|
||||||
detectionType: DetectionType;
|
|
||||||
features: Record<string, number>;
|
|
||||||
embedding: number[];
|
|
||||||
}> {
|
|
||||||
const embedding = await this.extract(audioBuffer);
|
|
||||||
|
|
||||||
// TODO: Run synthetic voice detection model
|
|
||||||
// For MVP, use heuristic based on embedding statistics
|
|
||||||
const confidence = this.estimateSyntheticConfidence(audioBuffer, embedding);
|
|
||||||
const detectionType =
|
|
||||||
confidence >= voicePrintEnv.SYNTHETIC_THRESHOLD
|
|
||||||
? DetectionType.SYNTHETIC_VOICE
|
|
||||||
: DetectionType.NATURAL;
|
|
||||||
|
|
||||||
const features = this.extractAnalysisFeatures(audioBuffer, embedding);
|
|
||||||
|
|
||||||
return {
|
|
||||||
confidence,
|
|
||||||
detectionType,
|
|
||||||
features,
|
|
||||||
embedding,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private estimateSyntheticConfidence(
|
|
||||||
buffer: Buffer,
|
|
||||||
embedding: number[]
|
|
||||||
): number {
|
|
||||||
// Heuristic features for synthetic detection
|
|
||||||
const meanAmplitude =
|
|
||||||
buffer.reduce((s, v) => s + v, 0) / buffer.length / 255;
|
|
||||||
const embeddingStdDev =
|
|
||||||
Math.sqrt(
|
|
||||||
embedding.reduce((s, v) => s + (v - embedding.reduce((a, b) => a + b) / embedding.length) ** 2, 0) /
|
|
||||||
embedding.length
|
|
||||||
) || 0;
|
|
||||||
|
|
||||||
// Combine features into confidence score
|
|
||||||
const amplitudeScore = Math.abs(meanAmplitude - 0.5) * 2;
|
|
||||||
const embeddingScore = 1.0 - Math.min(1.0, embeddingStdDev * 2);
|
|
||||||
|
|
||||||
return Math.min(
|
|
||||||
1.0,
|
|
||||||
amplitudeScore * 0.3 + embeddingScore * 0.4 + Math.random() * 0.3
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractAnalysisFeatures(
|
|
||||||
buffer: Buffer,
|
|
||||||
embedding: number[]
|
|
||||||
): Record<string, number> {
|
|
||||||
const meanAmplitude =
|
|
||||||
buffer.reduce((s, v) => s + v, 0) / buffer.length / 255;
|
|
||||||
const zeroCrossings = buffer.reduce((count, v, i, arr) => {
|
|
||||||
return i > 0 && ((v - 128) * (arr[i - 1] - 128) < 0) ? count + 1 : count;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
mean_amplitude: meanAmplitude,
|
|
||||||
zero_crossing_rate: zeroCrossings / buffer.length,
|
|
||||||
embedding_energy: embedding.reduce((s, v) => s + v * v, 0),
|
|
||||||
embedding_entropy: this.calculateEntropy(embedding),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private calculateEntropy(values: number[]): number {
|
|
||||||
const bins = 20;
|
|
||||||
const histogram = new Array(bins).fill(0);
|
|
||||||
const min = Math.min(...values);
|
|
||||||
const max = Math.max(...values);
|
|
||||||
const range = max - min || 1;
|
|
||||||
|
|
||||||
for (const v of values) {
|
|
||||||
const bin = Math.min(bins - 1, Math.floor(((v - min) / range) * bins));
|
|
||||||
histogram[bin]++;
|
|
||||||
}
|
|
||||||
|
|
||||||
let entropy = 0;
|
|
||||||
const total = values.length;
|
|
||||||
for (const count of histogram) {
|
|
||||||
if (count > 0) {
|
|
||||||
const p = count / total;
|
|
||||||
entropy -= p * Math.log2(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entropy;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FAISS index wrapper for voice fingerprint matching
|
|
||||||
export class FAISSIndex {
|
|
||||||
private indexPath: string;
|
|
||||||
private initialized = false;
|
|
||||||
|
|
||||||
constructor(path?: string) {
|
|
||||||
this.indexPath = path ?? voicePrintEnv.FAISS_INDEX_PATH;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize or load the FAISS index.
|
|
||||||
*/
|
|
||||||
async initialize(): Promise<void> {
|
|
||||||
if (this.initialized) return;
|
|
||||||
|
|
||||||
// TODO: Load FAISS index from disk
|
|
||||||
// const faiss = require('faiss-node');
|
|
||||||
// this.index = faiss.readIndex(this.indexPath);
|
|
||||||
|
|
||||||
this.initialized = true;
|
|
||||||
console.log(`FAISS index initialized at ${this.indexPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add an enrollment embedding to the index.
|
|
||||||
*/
|
|
||||||
async add(enrollmentId: string, embedding: number[]): Promise<void> {
|
|
||||||
await this.initialize();
|
|
||||||
|
|
||||||
// TODO: Add to FAISS index
|
|
||||||
// this.index.add([embedding]);
|
|
||||||
// Store mapping: enrollmentId -> index position
|
|
||||||
console.log(`Added enrollment ${enrollmentId} to FAISS index`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove an enrollment from the index.
|
|
||||||
*/
|
|
||||||
async remove(enrollmentId: string): Promise<void> {
|
|
||||||
await this.initialize();
|
|
||||||
|
|
||||||
// TODO: Remove from FAISS index
|
|
||||||
console.log(`Removed enrollment ${enrollmentId} from FAISS index`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search for similar voice embeddings.
|
|
||||||
*/
|
|
||||||
async search(
|
|
||||||
embedding: number[],
|
|
||||||
topK: number = 5
|
|
||||||
): Promise<Array<{ id: string; similarity: number }>> {
|
|
||||||
await this.initialize();
|
|
||||||
|
|
||||||
// TODO: Query FAISS index
|
|
||||||
// const [distances, indices] = this.index.search([embedding], topK);
|
|
||||||
// Map indices back to enrollment IDs
|
|
||||||
|
|
||||||
// Mock: return empty results
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save the index to disk.
|
|
||||||
*/
|
|
||||||
async save(): Promise<void> {
|
|
||||||
await this.initialize();
|
|
||||||
// TODO: Write FAISS index to disk
|
|
||||||
console.log(`FAISS index saved to ${this.indexPath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instances
|
|
||||||
export const audioPreprocessor = new AudioPreprocessor();
|
export const audioPreprocessor = new AudioPreprocessor();
|
||||||
export const voiceEnrollmentService = new VoiceEnrollmentService();
|
export const voiceEnrollmentService = new VoiceEnrollmentService();
|
||||||
export const analysisService = new AnalysisService();
|
export const analysisService = new AnalysisService();
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ model User {
|
|||||||
spamRules SpamRule[]
|
spamRules SpamRule[]
|
||||||
normalizedAlerts NormalizedAlert[]
|
normalizedAlerts NormalizedAlert[]
|
||||||
correlationGroups CorrelationGroup[]
|
correlationGroups CorrelationGroup[]
|
||||||
|
securityReports SecurityReport[]
|
||||||
|
analysisJobs AnalysisJob[]
|
||||||
|
|
||||||
// Audit
|
// Audit
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -51,6 +53,25 @@ enum UserRole {
|
|||||||
support
|
support
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum DetectionVerdict {
|
||||||
|
NATURAL
|
||||||
|
SYNTHETIC
|
||||||
|
UNCERTAIN
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AnalysisType {
|
||||||
|
SYNTHETIC_DETECTION
|
||||||
|
VOICE_MATCH
|
||||||
|
BATCH
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AnalysisJobStatus {
|
||||||
|
PENDING
|
||||||
|
RUNNING
|
||||||
|
COMPLETED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
@@ -336,6 +357,44 @@ model VoiceAnalysis {
|
|||||||
@@index([audioHash])
|
@@index([audioHash])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model AnalysisJob {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
analysisType AnalysisType
|
||||||
|
audioFilePath String
|
||||||
|
status AnalysisJobStatus
|
||||||
|
errorMessage String?
|
||||||
|
completedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
result AnalysisResult?
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([status])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model AnalysisResult {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
analysisJobId String @unique
|
||||||
|
syntheticScore Float
|
||||||
|
verdict DetectionVerdict
|
||||||
|
confidence Float
|
||||||
|
processingTimeMs Int
|
||||||
|
matchedEnrollmentId String?
|
||||||
|
matchedSimilarity Float?
|
||||||
|
modelVersion String?
|
||||||
|
|
||||||
|
analysisJob AnalysisJob @relation(fields: [analysisJobId], references: [id])
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([analysisJobId])
|
||||||
|
@@index([syntheticScore])
|
||||||
|
@@index([verdict])
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// SpamShield Models (Spam Detection)
|
// SpamShield Models (Spam Detection)
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -521,3 +580,99 @@ model CorrelationGroup {
|
|||||||
@@index([userId, status])
|
@@index([userId, status])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Report Generation Models
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
enum ReportType {
|
||||||
|
MONTHLY_PLUS
|
||||||
|
ANNUAL_PREMIUM
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReportStatus {
|
||||||
|
PENDING
|
||||||
|
GENERATING
|
||||||
|
COMPLETED
|
||||||
|
FAILED
|
||||||
|
DELIVERED
|
||||||
|
}
|
||||||
|
|
||||||
|
model SecurityReport {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
subscriptionId String
|
||||||
|
reportType ReportType
|
||||||
|
status ReportStatus @default(PENDING)
|
||||||
|
periodStart DateTime
|
||||||
|
periodEnd DateTime
|
||||||
|
title String
|
||||||
|
summary String?
|
||||||
|
htmlContent String?
|
||||||
|
pdfUrl String?
|
||||||
|
dataPayload Json?
|
||||||
|
error String?
|
||||||
|
scheduledFor DateTime?
|
||||||
|
deliveredAt DateTime?
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([subscriptionId])
|
||||||
|
@@index([reportType])
|
||||||
|
@@index([status])
|
||||||
|
@@index([periodStart, periodEnd])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Waitlist & Marketing Models
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model WaitlistEntry {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
email String
|
||||||
|
name String?
|
||||||
|
source String? // landing_page, blog, referral, social, paid_search
|
||||||
|
tier SubscriptionTier? // interest level
|
||||||
|
utmSource String?
|
||||||
|
utmMedium String?
|
||||||
|
utmCampaign String?
|
||||||
|
metadata Json? // Browser, device, location, etc.
|
||||||
|
|
||||||
|
// Conversion tracking
|
||||||
|
convertedAt DateTime?
|
||||||
|
convertedToUserId String?
|
||||||
|
convertedToSubscriptionId String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([email])
|
||||||
|
@@index([source])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model BlogPost {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
slug String @unique
|
||||||
|
title String
|
||||||
|
excerpt String?
|
||||||
|
content String
|
||||||
|
authorName String?
|
||||||
|
coverImageUrl String?
|
||||||
|
tags String[] // Array of tag strings
|
||||||
|
published Boolean @default(false)
|
||||||
|
publishedAt DateTime?
|
||||||
|
viewCount Int @default(0)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([slug])
|
||||||
|
@@index([published, publishedAt])
|
||||||
|
@@index([tags])
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,10 +48,15 @@ export type {
|
|||||||
Alert,
|
Alert,
|
||||||
VoiceEnrollment,
|
VoiceEnrollment,
|
||||||
VoiceAnalysis,
|
VoiceAnalysis,
|
||||||
|
AnalysisJob,
|
||||||
|
AnalysisResult,
|
||||||
SpamFeedback,
|
SpamFeedback,
|
||||||
SpamRule,
|
SpamRule,
|
||||||
AuditLog,
|
AuditLog,
|
||||||
KPISnapshot,
|
KPISnapshot,
|
||||||
|
SecurityReport,
|
||||||
|
WaitlistEntry,
|
||||||
|
BlogPost,
|
||||||
UserRole,
|
UserRole,
|
||||||
FamilyMemberRole,
|
FamilyMemberRole,
|
||||||
SubscriptionTier,
|
SubscriptionTier,
|
||||||
@@ -65,6 +70,11 @@ export type {
|
|||||||
FeedbackType,
|
FeedbackType,
|
||||||
RuleType,
|
RuleType,
|
||||||
RuleAction,
|
RuleAction,
|
||||||
|
ReportType,
|
||||||
|
ReportStatus,
|
||||||
|
AnalysisType,
|
||||||
|
AnalysisJobStatus,
|
||||||
|
DetectionVerdict,
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
|
|
||||||
export * as PrismaModels from '@prisma/client';
|
export * as PrismaModels from '@prisma/client';
|
||||||
|
|||||||
23
packages/extension/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "@shieldai/extension",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "ShieldAI Browser Extension - Phishing & Spam Protection",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
|
"build:chrome": "vite build --mode chrome",
|
||||||
|
"build:firefox": "vite build --mode firefox",
|
||||||
|
"dev": "vite build --watch --mode chrome",
|
||||||
|
"test": "vitest run",
|
||||||
|
"lint": "eslint src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@shieldai/types": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/chrome": "^0.0.268",
|
||||||
|
"vite": "^5.4.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
packages/extension/public/icons/icon128.png
Normal file
|
After Width: | Height: | Size: 64 KiB |