diff --git a/.githooks/pre-push b/.githooks/pre-push
new file mode 100755
index 00000000..ef4dd517
--- /dev/null
+++ b/.githooks/pre-push
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+# Pre-push hook: run gitleaks to detect secrets before pushing.
+# Install: git config core.hooksPath .githooks
+set -euo pipefail
+
+if ! command -v gitleaks &>/dev/null; then
+ echo "gitleaks not found — skipping secret scan (install from https://github.com/gitleaks/gitleaks)"
+ exit 0
+fi
+
+echo "Running gitleaks secret scan..."
+gitleaks protect --staged --config .gitleaks.toml --verbose
diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml
new file mode 100644
index 00000000..5e2caccd
--- /dev/null
+++ b/.github/workflows/license-check.yml
@@ -0,0 +1,130 @@
+name: License Compliance Check
+
+on:
+ push:
+ branches: [main, develop]
+ pull_request:
+ branches: [main, develop]
+
+jobs:
+ rust-licenses-contracts:
+ name: Rust License Check — contracts/predict-iq
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Cache cargo registry
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ key: ${{ runner.os }}-cargo-deny-contracts-${{ hashFiles('**/Cargo.lock') }}
+
+ - name: Install cargo-deny
+ run: cargo install cargo-deny --locked
+
+ - name: Check licenses — contracts workspace
+ run: cargo deny check licenses
+ working-directory: contracts/predict-iq
+
+ rust-licenses-api:
+ name: Rust License Check — services/api
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Cache cargo registry
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ key: ${{ runner.os }}-cargo-deny-api-${{ hashFiles('services/api/Cargo.lock') }}
+
+ - name: Install cargo-deny
+ run: cargo install cargo-deny --locked
+
+ - name: Check licenses — services/api
+ run: cargo deny check licenses
+ working-directory: services/api
+
+ npm-licenses-frontend:
+ name: NPM License Check — frontend
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: "18"
+ cache: "npm"
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Install dependencies
+ run: npm ci
+ working-directory: frontend
+
+ - name: Install license-checker
+ run: npm install -g license-checker
+
+ - name: Check licenses — frontend
+ run: |
+ license-checker \
+ --onlyAllow "$(node -e "
+ const cfg = require('./.license-checker.json');
+ process.stdout.write(cfg.onlyAllow.join(';'));
+ ")" \
+ --excludePrivatePackages \
+ --production
+ working-directory: frontend
+
+ npm-licenses-tts:
+ name: NPM License Check — services/tts
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: "18"
+ cache: "npm"
+ cache-dependency-path: services/tts/package-lock.json
+
+ - name: Install dependencies
+ run: npm ci
+ working-directory: services/tts
+
+ - name: Install license-checker
+ run: npm install -g license-checker
+
+ - name: Check licenses — services/tts
+ run: |
+ license-checker \
+ --onlyAllow "$(node -e "
+ const cfg = require('./.license-checker.json');
+ process.stdout.write(cfg.onlyAllow.join(';'));
+ ")" \
+ --excludePrivatePackages \
+ --production
+ working-directory: services/tts
+
+ all-license-checks-passed:
+ name: All License Checks Passed
+ needs:
+ - rust-licenses-contracts
+ - rust-licenses-api
+ - npm-licenses-frontend
+ - npm-licenses-tts
+ runs-on: ubuntu-latest
+ steps:
+ - name: Success
+ run: echo "All dependency licenses are compliant."
diff --git a/.github/workflows/openapi-validation.yml b/.github/workflows/openapi-validation.yml
new file mode 100644
index 00000000..44b4d540
--- /dev/null
+++ b/.github/workflows/openapi-validation.yml
@@ -0,0 +1,82 @@
+name: OpenAPI Spec Validation
+
+on:
+ push:
+ branches: [main, develop]
+ paths:
+ - "services/api/openapi.yaml"
+ - "services/api/src/**"
+ - "services/api/tests/openapi_contract_test.rs"
+ pull_request:
+ branches: [main, develop]
+ paths:
+ - "services/api/openapi.yaml"
+ - "services/api/src/**"
+ - "services/api/tests/openapi_contract_test.rs"
+
+jobs:
+ spectral-lint:
+ name: Spectral — OpenAPI structural lint
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: "18"
+
+ - name: Install Spectral
+ run: npm install -g @stoplight/spectral-cli
+
+ - name: Lint openapi.yaml
+ run: spectral lint services/api/openapi.yaml --ruleset @stoplight/spectral-oas
+
+ - name: Fail on errors
+ if: failure()
+ run: |
+ echo "Spectral found structural issues in services/api/openapi.yaml."
+ echo "Run 'spectral lint services/api/openapi.yaml --ruleset @stoplight/spectral-oas' locally to see details."
+ exit 1
+
+ contract-tests:
+ name: OpenAPI Contract Tests
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Cache cargo dependencies
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ key: ${{ runner.os }}-cargo-api-openapi-${{ hashFiles('services/api/Cargo.lock') }}
+
+ - name: Run OpenAPI contract tests
+ run: cargo test --test openapi_contract_test -- --nocapture
+ working-directory: services/api
+
+ - name: Fail if spec and routes diverge
+ if: failure()
+ run: |
+ echo "OpenAPI contract tests failed."
+ echo "The spec (services/api/openapi.yaml) and SPEC_ROUTES in"
+ echo "services/api/tests/openapi_contract_test.rs have diverged."
+ echo ""
+ echo "To fix:"
+ echo " 1. If you added/removed a route in Rust: update openapi.yaml to match."
+ echo " 2. If you updated openapi.yaml: update SPEC_ROUTES in the contract test."
+ exit 1
+
+ all-openapi-checks-passed:
+ name: All OpenAPI Checks Passed
+ needs: [spectral-lint, contract-tests]
+ runs-on: ubuntu-latest
+ steps:
+ - name: Success
+ run: echo "OpenAPI spec is valid and in sync with routes."
diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml
new file mode 100644
index 00000000..68e2b8a8
--- /dev/null
+++ b/.github/workflows/terraform.yml
@@ -0,0 +1,204 @@
+name: Terraform Plan
+
+on:
+ pull_request:
+ branches: [main, develop]
+ paths:
+ - "infrastructure/terraform/**"
+
+permissions:
+ contents: read
+ pull-requests: write
+
+env:
+ TF_WORKING_DIR: infrastructure/terraform
+ TF_VERSION: "1.9.0"
+
+jobs:
+ terraform-fmt:
+ name: Terraform Format Check
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Terraform
+ uses: hashicorp/setup-terraform@v3
+ with:
+ terraform_version: ${{ env.TF_VERSION }}
+
+ - name: Terraform fmt
+ id: fmt
+ run: terraform fmt -check -recursive
+ working-directory: ${{ env.TF_WORKING_DIR }}
+ continue-on-error: true
+
+ - name: Post fmt failure comment
+ if: steps.fmt.outcome == 'failure'
+ uses: actions/github-script@v9
+ with:
+ script: |
+ github.rest.issues.createComment({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ body: `## Terraform Format Check Failed\n\nRun \`terraform fmt -recursive\` inside \`infrastructure/terraform/\` and commit the result.`
+ })
+
+ - name: Fail on fmt errors
+ if: steps.fmt.outcome == 'failure'
+ run: exit 1
+
+ terraform-validate:
+ name: Terraform Validate
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ environment: [dev, staging, production]
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Terraform
+ uses: hashicorp/setup-terraform@v3
+ with:
+ terraform_version: ${{ env.TF_VERSION }}
+
+ - name: Terraform Init (local backend for validation)
+ run: terraform init -backend=false
+ working-directory: ${{ env.TF_WORKING_DIR }}
+
+ - name: Terraform Validate
+ run: terraform validate
+ working-directory: ${{ env.TF_WORKING_DIR }}
+
+ terraform-plan:
+ name: Terraform Plan (${{ matrix.environment }})
+ runs-on: ubuntu-latest
+ needs: [terraform-fmt, terraform-validate]
+ strategy:
+ matrix:
+ environment: [dev, staging, production]
+ environment: ${{ matrix.environment }}
+ env:
+ TF_VAR_environment: ${{ matrix.environment }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Terraform
+ uses: hashicorp/setup-terraform@v3
+ with:
+ terraform_version: ${{ env.TF_VERSION }}
+ terraform_wrapper: true
+
+ - 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: ${{ secrets.AWS_REGION }}
+
+ - name: Terraform Init
+ id: init
+ run: |
+ terraform init \
+ -backend-config=backend-config.hcl \
+ -backend-config="key=${{ matrix.environment }}/terraform.tfstate"
+ working-directory: ${{ env.TF_WORKING_DIR }}
+
+ - name: Resolve var-file path
+ id: varfile
+ run: |
+ # dev uses a flat file; staging and production use a subdirectory
+ if [ -f "environments/${{ matrix.environment }}.tfvars" ]; then
+ echo "path=environments/${{ matrix.environment }}.tfvars" >> "$GITHUB_OUTPUT"
+ else
+ echo "path=environments/${{ matrix.environment }}/terraform.tfvars" >> "$GITHUB_OUTPUT"
+ fi
+ working-directory: ${{ env.TF_WORKING_DIR }}
+
+ - name: Terraform Plan
+ id: plan
+ run: |
+ terraform plan \
+ -var-file="${{ steps.varfile.outputs.path }}" \
+ -out="${{ matrix.environment }}.tfplan" \
+ -no-color \
+ 2>&1 | tee plan-output.txt
+ working-directory: ${{ env.TF_WORKING_DIR }}
+ continue-on-error: true
+
+ - name: Upload plan artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: terraform-plan-${{ matrix.environment }}-${{ github.sha }}
+ path: ${{ env.TF_WORKING_DIR }}/${{ matrix.environment }}.tfplan
+ retention-days: 7
+
+ - name: Post plan comment
+ uses: actions/github-script@v9
+ with:
+ script: |
+ const fs = require('fs');
+ const planOutput = fs.readFileSync('${{ env.TF_WORKING_DIR }}/plan-output.txt', 'utf8');
+ const outcome = '${{ steps.plan.outcome }}';
+ const env = '${{ matrix.environment }}';
+ const statusEmoji = outcome === 'success' ? '✅' : '❌';
+
+ const truncated = planOutput.length > 60000
+ ? planOutput.slice(0, 60000) + '\n\n... (truncated — download the plan artifact for full output)'
+ : planOutput;
+
+ const body = [
+ `## ${statusEmoji} Terraform Plan — \`${env}\``,
+ '',
+ `**Status:** ${outcome} | **Commit:** ${context.sha.slice(0, 7)}`,
+ '',
+ 'Show plan
',
+ '',
+ '```hcl',
+ truncated,
+ '```',
+ '',
+ ' ',
+ '',
+ `> Plan artifact: \`terraform-plan-${env}-${context.sha}\` (retained 7 days)`,
+ ].join('\n');
+
+ // Find and update an existing plan comment from this workflow if present
+ const { data: comments } = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ });
+
+ const marker = `## ${statusEmoji} Terraform Plan — \`${env}\``;
+ const existing = comments.find(c =>
+ c.user.login === 'github-actions[bot]' && c.body.includes(`Terraform Plan — \`${env}\``)
+ );
+
+ if (existing) {
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: existing.id,
+ body,
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body,
+ });
+ }
+
+ - name: Fail if plan failed
+ if: steps.plan.outcome == 'failure'
+ run: exit 1
+
+ all-terraform-checks-passed:
+ name: All Terraform Checks Passed
+ needs: [terraform-fmt, terraform-validate, terraform-plan]
+ runs-on: ubuntu-latest
+ steps:
+ - name: Success
+ run: echo "Terraform fmt, validate, and plan all passed."
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index f270b8ad..cd93b121 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -327,10 +327,14 @@ jobs:
- name: Install cargo-audit
run: cargo install cargo-audit
- - name: Run security audit
+ - name: Run security audit — contracts/predict-iq
run: cargo audit
working-directory: contracts/predict-iq
+ - name: Run security audit — services/api
+ run: cargo audit
+ working-directory: services/api
+
sast-scanning:
name: SAST Security Scanning
runs-on: ubuntu-latest
@@ -346,6 +350,7 @@ jobs:
config: >-
p/security-audit
p/rust
+ p/nodejs
p/typescript
p/javascript
generateSarif: true
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a588632b..7281887c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -267,6 +267,47 @@ Sensitive paths have designated reviewers defined in [`.github/CODEOWNERS`](.git
---
+## SAST and Security Tooling
+
+This project enforces security scanning at every stage of the development lifecycle.
+
+### Tools and thresholds
+
+| Tool | Scope | Threshold |
+|------|-------|-----------|
+| [cargo-audit](https://github.com/RustSec/rustsec/tree/main/cargo-audit) | Rust dependencies (contracts + API) | Fails CI on any known CVE |
+| [Semgrep](https://semgrep.dev/) | Rust, Node.js, TypeScript, JavaScript source | Fails CI on `error`-level findings; rulesets: `p/security-audit`, `p/rust`, `p/nodejs`, `p/typescript`, `p/javascript` |
+| [Gitleaks](https://github.com/gitleaks/gitleaks) | Git history and staged changes | Fails CI and pre-push hook on any detected secret |
+| [TruffleHog](https://github.com/trufflesecurity/trufflehog) | Git history (verified secrets only) | Fails CI on verified secrets |
+| [Trivy](https://github.com/aquasecurity/trivy) | Filesystem and container images | Fails CI on `CRITICAL` or `HIGH` findings |
+| [CodeQL](https://codeql.github.com/) | JavaScript, TypeScript, Rust | Fails CI on security-extended queries |
+
+### Local setup
+
+Install and activate the gitleaks pre-push hook to catch secrets before they reach CI:
+
+```bash
+# Install gitleaks (macOS)
+brew install gitleaks
+
+# Or on Linux
+wget https://github.com/gitleaks/gitleaks/releases/latest/download/gitleaks_linux_amd64 -O gitleaks
+sudo install gitleaks /usr/local/bin/
+
+# Activate the project hook (run once after cloning)
+git config core.hooksPath .githooks
+```
+
+The hook runs `gitleaks protect --staged` on every `git push`, scanning staged commits against `.gitleaks.toml`. To skip in an emergency (e.g. a false positive you've already triaged):
+
+```bash
+SKIP=gitleaks git push
+```
+
+Custom rules for Stellar keys and API tokens are defined in `.gitleaks.toml`.
+
+---
+
## Questions?
Open an issue or start a discussion on [GitHub](https://github.com/solutions-plug/predictIQ/issues).
diff --git a/deny.toml b/deny.toml
new file mode 100644
index 00000000..ac2fe80e
--- /dev/null
+++ b/deny.toml
@@ -0,0 +1,54 @@
+# cargo-deny configuration for the contracts workspace (contracts/predict-iq).
+# Run locally: cargo deny check licenses
+# CI: .github/workflows/license-check.yml
+
+[licenses]
+# Acceptable SPDX license identifiers.
+# Any dependency under a license not listed here will fail CI.
+allow = [
+ "MIT",
+ "Apache-2.0",
+ "Apache-2.0 WITH LLVM-exception",
+ "BSD-2-Clause",
+ "BSD-3-Clause",
+ "ISC",
+ "Unicode-DFS-2016",
+ "CC0-1.0",
+ "Zlib",
+ "0BSD",
+]
+
+# Copyleft and network-copyleft licenses that must never appear.
+deny = [
+ "GPL-2.0",
+ "GPL-3.0",
+ "LGPL-2.0",
+ "LGPL-2.1",
+ "LGPL-3.0",
+ "AGPL-3.0",
+ "OSL-3.0",
+ "EUPL-1.1",
+ "EUPL-1.2",
+]
+
+# Confidence threshold for license detection (0.0 – 1.0).
+confidence-threshold = 0.8
+
+[advisories]
+# Use the RustSec advisory database.
+db-path = "~/.cargo/advisory-db"
+db-urls = ["https://github.com/rustsec/advisory-db"]
+# cargo-audit handles vulnerability checks separately; deny advisories only
+# for unmaintained crates to avoid duplicating audit output.
+ignore = []
+
+[bans]
+# Prevent multiple versions of the same crate from being compiled unless
+# explicitly allowed below.
+multiple-versions = "warn"
+
+[sources]
+# Only allow crates from crates.io and the project's own git repos.
+unknown-registry = "deny"
+unknown-git = "deny"
+allow-registry = ["https://github.com/rust-lang/crates.io-index"]
diff --git a/frontend/.license-checker.json b/frontend/.license-checker.json
new file mode 100644
index 00000000..dfaaf39b
--- /dev/null
+++ b/frontend/.license-checker.json
@@ -0,0 +1,18 @@
+{
+ "onlyAllow": [
+ "MIT",
+ "MIT*",
+ "ISC",
+ "BSD-2-Clause",
+ "BSD-3-Clause",
+ "Apache-2.0",
+ "Apache*",
+ "CC0-1.0",
+ "CC-BY-3.0",
+ "CC-BY-4.0",
+ "Unlicense",
+ "0BSD",
+ "Python-2.0",
+ "BlueOak-1.0.0"
+ ]
+}
diff --git a/services/api/deny.toml b/services/api/deny.toml
new file mode 100644
index 00000000..4aca3777
--- /dev/null
+++ b/services/api/deny.toml
@@ -0,0 +1,45 @@
+# cargo-deny configuration for services/api.
+# Run locally: cargo deny check licenses
+# CI: .github/workflows/license-check.yml
+
+[licenses]
+allow = [
+ "MIT",
+ "Apache-2.0",
+ "Apache-2.0 WITH LLVM-exception",
+ "BSD-2-Clause",
+ "BSD-3-Clause",
+ "ISC",
+ "Unicode-DFS-2016",
+ "CC0-1.0",
+ "Zlib",
+ "0BSD",
+ "OpenSSL",
+]
+
+deny = [
+ "GPL-2.0",
+ "GPL-3.0",
+ "LGPL-2.0",
+ "LGPL-2.1",
+ "LGPL-3.0",
+ "AGPL-3.0",
+ "OSL-3.0",
+ "EUPL-1.1",
+ "EUPL-1.2",
+]
+
+confidence-threshold = 0.8
+
+[advisories]
+db-path = "~/.cargo/advisory-db"
+db-urls = ["https://github.com/rustsec/advisory-db"]
+ignore = []
+
+[bans]
+multiple-versions = "warn"
+
+[sources]
+unknown-registry = "deny"
+unknown-git = "deny"
+allow-registry = ["https://github.com/rust-lang/crates.io-index"]
diff --git a/services/api/tests/openapi_contract_test.rs b/services/api/tests/openapi_contract_test.rs
index c095374b..16e9bae9 100644
--- a/services/api/tests/openapi_contract_test.rs
+++ b/services/api/tests/openapi_contract_test.rs
@@ -2,24 +2,28 @@
///
/// Validates that every path declared in openapi.yaml is registered in the
/// Axum router, and that admin routes declare the ApiKeyAuth security scheme.
+///
+/// The SPEC_ROUTES table is the authoritative mirror of openapi.yaml paths.
+/// The yaml_paths_match_spec_routes test parses the YAML at test-time and
+/// fails if the two diverge, preventing silent spec drift.
#[cfg(test)]
mod tests {
use std::collections::HashSet;
- /// Paths declared in openapi.yaml (method + path pairs).
- /// Keep in sync with services/api/openapi.yaml.
+ /// All (METHOD, path) pairs declared in openapi.yaml.
+ /// Must stay in sync — the yaml_paths_match_spec_routes test enforces this.
const SPEC_ROUTES: &[(&str, &str)] = &[
("GET", "/health"),
- ("GET", "/api/statistics"),
- ("GET", "/api/markets/featured"),
- ("GET", "/api/content"),
- ("POST", "/api/markets/{market_id}/resolve"),
- ("GET", "/api/blockchain/health"),
- ("GET", "/api/blockchain/markets/{market_id}"),
- ("GET", "/api/blockchain/stats"),
- ("GET", "/api/blockchain/users/{user}/bets"),
- ("GET", "/api/blockchain/oracle/{market_id}"),
- ("GET", "/api/blockchain/tx/{tx_hash}"),
+ ("GET", "/api/v1/statistics"),
+ ("GET", "/api/v1/markets/featured"),
+ ("GET", "/api/v1/content"),
+ ("POST", "/api/v1/markets/{market_id}/resolve"),
+ ("GET", "/api/v1/blockchain/health"),
+ ("GET", "/api/v1/blockchain/markets/{market_id}"),
+ ("GET", "/api/v1/blockchain/stats"),
+ ("GET", "/api/v1/blockchain/users/{user}/bets"),
+ ("GET", "/api/v1/blockchain/oracle/{market_id}"),
+ ("GET", "/api/v1/blockchain/tx/{tx_hash}"),
("POST", "/api/v1/newsletter/subscribe"),
("GET", "/api/v1/newsletter/confirm"),
("DELETE", "/api/v1/newsletter/unsubscribe"),
@@ -29,18 +33,58 @@ mod tests {
("POST", "/api/v1/email/test"),
("GET", "/api/v1/email/analytics"),
("GET", "/api/v1/email/queue/stats"),
+ ("POST", "/api/blockchain/replay"),
+ ("GET", "/api/v1/email/queue/dead-letter"),
+ ("POST", "/api/v1/email/queue/dead-letter/{job_id}/requeue"),
+ ("GET", "/api/v1/audit/logs"),
+ ("GET", "/api/v1/audit/statistics"),
("POST", "/webhooks/sendgrid"),
];
/// Admin routes that must declare ApiKeyAuth security in the spec.
const ADMIN_ROUTES: &[(&str, &str)] = &[
- ("POST", "/api/markets/{market_id}/resolve"),
+ ("POST", "/api/v1/markets/{market_id}/resolve"),
("GET", "/api/v1/email/preview/{template_name}"),
("POST", "/api/v1/email/test"),
("GET", "/api/v1/email/analytics"),
("GET", "/api/v1/email/queue/stats"),
+ ("POST", "/api/blockchain/replay"),
+ ("GET", "/api/v1/email/queue/dead-letter"),
+ ("POST", "/api/v1/email/queue/dead-letter/{job_id}/requeue"),
+ ("GET", "/api/v1/audit/logs"),
+ ("GET", "/api/v1/audit/statistics"),
];
+ const OPENAPI_YAML: &str = include_str!("../openapi.yaml");
+
+ /// Parse (METHOD, path) pairs directly from openapi.yaml and return them.
+ /// Uses line-by-line parsing so no YAML library is required in dev-deps.
+ fn yaml_routes() -> Vec<(String, String)> {
+ let methods = ["get", "post", "put", "delete", "patch"];
+ let mut routes = Vec::new();
+ let mut current_path: Option = None;
+
+ for line in OPENAPI_YAML.lines() {
+ // Top-level path entries start with exactly two spaces then "/"
+ if let Some(rest) = line.strip_prefix(" /") {
+ if let Some(path_str) = rest.strip_suffix(':') {
+ current_path = Some(format!("/{}", path_str));
+ continue;
+ }
+ }
+ // Method entries are indented four spaces
+ if let Some(path) = ¤t_path {
+ for method in methods {
+ let prefix = format!(" {}:", method);
+ if line == prefix {
+ routes.push((method.to_uppercase(), path.clone()));
+ }
+ }
+ }
+ }
+ routes
+ }
+
#[test]
fn spec_routes_are_unique() {
let set: HashSet<_> = SPEC_ROUTES.iter().collect();
@@ -63,60 +107,125 @@ mod tests {
}
}
+ /// Fail if openapi.yaml contains a route missing from SPEC_ROUTES.
+ /// This catches new endpoints added to the spec without updating the table.
+ #[test]
+ fn yaml_paths_match_spec_routes_no_missing() {
+ let spec_set: HashSet<(&str, &str)> =
+ SPEC_ROUTES.iter().map(|(m, p)| (*m, *p)).collect();
+ let yaml = yaml_routes();
+ let mut missing = Vec::new();
+ for (method, path) in &yaml {
+ if !spec_set.contains(&(method.as_str(), path.as_str())) {
+ missing.push(format!("{} {}", method, path));
+ }
+ }
+ assert!(
+ missing.is_empty(),
+ "openapi.yaml has routes not listed in SPEC_ROUTES — add them:\n {}",
+ missing.join("\n ")
+ );
+ }
+
+ /// Fail if SPEC_ROUTES lists a route not present in openapi.yaml.
+ /// This catches stale entries left behind when endpoints are removed from the spec.
+ #[test]
+ fn spec_routes_no_stale_entries() {
+ let yaml_set: HashSet<(String, String)> = yaml_routes().into_iter().collect();
+ let mut stale = Vec::new();
+ for (method, path) in SPEC_ROUTES {
+ if !yaml_set.contains(&(method.to_string(), path.to_string())) {
+ stale.push(format!("{} {}", method, path));
+ }
+ }
+ assert!(
+ stale.is_empty(),
+ "SPEC_ROUTES has entries missing from openapi.yaml — remove them:\n {}",
+ stale.join("\n ")
+ );
+ }
+
#[test]
fn openapi_yaml_server_url_matches_default_bind() {
- let yaml = include_str!("../openapi.yaml");
// Default bind is 0.0.0.0:8080; the spec server URL must use port 8080.
assert!(
- yaml.contains("localhost:8080"),
+ OPENAPI_YAML.contains(":8080"),
"openapi.yaml server URL must reference port 8080 (default API_BIND_ADDR)"
);
}
#[test]
fn openapi_yaml_declares_api_key_security_scheme() {
- let yaml = include_str!("../openapi.yaml");
assert!(
- yaml.contains("ApiKeyAuth"),
+ OPENAPI_YAML.contains("ApiKeyAuth"),
"openapi.yaml must declare the ApiKeyAuth security scheme"
);
assert!(
- yaml.contains("X-API-Key"),
+ OPENAPI_YAML.contains("X-API-Key"),
"ApiKeyAuth scheme must use X-API-Key header"
);
}
#[test]
fn admin_routes_have_security_in_spec() {
- let yaml = include_str!("../openapi.yaml");
- // Each admin operationId must appear alongside a security block.
let admin_operation_ids = [
"resolveMarket",
"emailPreview",
"emailSendTest",
"getEmailAnalytics",
"getEmailQueueStats",
+ "blockchainReplay",
+ "getEmailDeadLetterList",
+ "requeueEmailDeadLetterJob",
+ "getAuditLogs",
+ "getAuditStatistics",
];
for op_id in admin_operation_ids {
assert!(
- yaml.contains(op_id),
+ OPENAPI_YAML.contains(op_id),
"operationId {op_id} missing from openapi.yaml"
);
}
- // The spec must contain at least one security: - ApiKeyAuth: [] block.
assert!(
- yaml.contains("- ApiKeyAuth: []"),
+ OPENAPI_YAML.contains("- ApiKeyAuth: []"),
"openapi.yaml must apply ApiKeyAuth security to admin routes"
);
}
#[test]
fn api_error_schema_has_code_field() {
- let yaml = include_str!("../openapi.yaml");
- // ApiError schema must include the machine-readable `code` field.
assert!(
- yaml.contains("code:") && yaml.contains("INTERNAL_ERROR"),
+ OPENAPI_YAML.contains("code:") && OPENAPI_YAML.contains("INTERNAL_ERROR"),
"ApiError schema must declare a `code` field with example INTERNAL_ERROR"
);
}
+
+ /// Every path in the spec must have an operationId so CI tools and clients
+ /// can reference operations by stable name.
+ #[test]
+ fn every_route_has_operation_id() {
+ let yaml = yaml_routes();
+ let total = yaml.len();
+ let id_count = OPENAPI_YAML.matches("operationId:").count();
+ assert_eq!(
+ id_count, total,
+ "expected {total} operationId entries (one per route), found {id_count}"
+ );
+ }
+
+ /// Every path in the spec must declare at least one success (2xx) response.
+ #[test]
+ fn every_route_has_success_response() {
+ // Count "200:", "201:", "204:" response codes; there must be at least
+ // one per route to satisfy the acceptance criterion.
+ let success_count = OPENAPI_YAML.matches("\"200\"").count()
+ + OPENAPI_YAML.matches("\"201\"").count()
+ + OPENAPI_YAML.matches("\"204\"").count();
+ let route_count = yaml_routes().len();
+ assert!(
+ success_count >= route_count,
+ "fewer success responses ({success_count}) than routes ({route_count}) — \
+ every endpoint must document at least one 2xx response"
+ );
+ }
}
diff --git a/services/tts/.license-checker.json b/services/tts/.license-checker.json
new file mode 100644
index 00000000..dfaaf39b
--- /dev/null
+++ b/services/tts/.license-checker.json
@@ -0,0 +1,18 @@
+{
+ "onlyAllow": [
+ "MIT",
+ "MIT*",
+ "ISC",
+ "BSD-2-Clause",
+ "BSD-3-Clause",
+ "Apache-2.0",
+ "Apache*",
+ "CC0-1.0",
+ "CC-BY-3.0",
+ "CC-BY-4.0",
+ "Unlicense",
+ "0BSD",
+ "Python-2.0",
+ "BlueOak-1.0.0"
+ ]
+}