diff --git a/.github/workflows/protect-changelog.yml b/.github/workflows/protect-changelog.yml new file mode 100644 index 00000000..7e70315d --- /dev/null +++ b/.github/workflows/protect-changelog.yml @@ -0,0 +1,18 @@ +name: Protect CHANGELOG + +on: + pull_request: + paths: + - "CHANGELOG.md" + +jobs: + block-manual-changelog: + name: Block manual CHANGELOG edits + runs-on: ubuntu-latest + steps: + - name: Fail if CHANGELOG.md was manually edited + run: | + echo "❌ CHANGELOG.md must not be edited manually." + echo "It is auto-generated by git-cliff on release." + echo "Remove the CHANGELOG.md change from this PR." + exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1df4b44d..e80fb9ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,77 +1,44 @@ -name: Semantic Release +name: Release on: push: - branches: [main] + tags: + - "v[0-9]+.[0-9]+.[0-9]*" permissions: contents: write - pull-requests: write jobs: - release: - name: Semantic Release + changelog: + name: Generate Changelog & Release runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - name: Setup Node.js - uses: actions/setup-node@v4 + - name: Generate changelog with git-cliff + uses: orhun/git-cliff-action@v3 + id: cliff with: - node-version: '18' - - - name: Install semantic-release - run: npm install -g semantic-release @semantic-release/changelog @semantic-release/git @semantic-release/github + config: cliff.toml + args: --verbose --tag ${{ github.ref_name }} + env: + OUTPUT: CHANGELOG.md + GITHUB_REPO: ${{ github.repository }} - - name: Configure git + - name: Commit updated CHANGELOG.md run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md + git diff --cached --quiet || git commit -m "chore(changelog): update for ${{ github.ref_name }} [skip ci]" + git push origin HEAD:main - - name: Create .releaserc.json - run: | - cat > .releaserc.json << 'EOF' - { - "branches": ["main"], - "plugins": [ - "@semantic-release/commit-analyzer", - "@semantic-release/release-notes-generator", - [ - "@semantic-release/changelog", - { - "changelogFile": "CHANGELOG.md" - } - ], - [ - "@semantic-release/git", - { - "assets": ["CHANGELOG.md", "package.json", "package-lock.json"], - "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" - } - ], - [ - "@semantic-release/github", - { - "successComment": "🎉 This issue has been resolved in version ${nextRelease.version}", - "failTitle": "The automated release failed" - } - ] - ] - } - EOF - - - name: Run semantic-release + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + body: ${{ steps.cliff.outputs.content }} + tag_name: ${{ github.ref_name }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: semantic-release - - - name: Upload release artifacts - if: success() - uses: actions/upload-artifact@v4 - with: - name: release-notes - path: CHANGELOG.md - if-no-files-found: ignore diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..cc216fce --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,196 @@ +# Contributing to PredictIQ + +Thank you for your interest in contributing! This guide covers everything you need to get started. + +## Table of Contents + +- [Development Setup](#development-setup) +- [Branch Naming](#branch-naming) +- [Commit Conventions](#commit-conventions) +- [Pull Request Process](#pull-request-process) +- [Running Tests](#running-tests) +- [Code Style](#code-style) + +--- + +## Development Setup + +### Prerequisites + +- [Rust](https://rustup.rs/) (stable toolchain) +- [Node.js](https://nodejs.org/) 18+ +- [Docker](https://docs.docker.com/get-docker/) and Docker Compose +- [PostgreSQL](https://www.postgresql.org/) 15+ (or use the provided Docker Compose stack) + +### Getting Started + +```bash +# Clone the repository +git clone https://github.com/solutions-plug/predictIQ.git +cd predictIQ + +# Start backing services (Postgres, Redis, etc.) +docker compose up -d + +# API service +cd services/api +cp .env.example .env # fill in required values +cargo build + +# Frontend +cd frontend +cp .env.example .env.local # fill in required values +npm install +npm run dev + +# TTS service +cd services/tts +npm install +npm run dev +``` + +--- + +## Branch Naming + +Use the following prefixes: + +| Prefix | Purpose | +|--------|---------| +| `feat/` | New feature | +| `fix/` | Bug fix | +| `chore/` | Maintenance, dependency updates | +| `docs/` | Documentation only | +| `refactor/` | Code refactoring without behaviour change | +| `perf/` | Performance improvement | +| `ci/` | CI/CD changes | + +Examples: `feat/market-resolution`, `fix/rate-limit-header`, `docs/contributing` + +--- + +## Commit Conventions + +This project uses **[Conventional Commits](https://www.conventionalcommits.org/)**. +The CHANGELOG is **auto-generated** from commit messages via [git-cliff](https://git-cliff.org/) — do not edit `CHANGELOG.md` manually. + +### Format + +``` +(): + +[optional body] + +[optional footer(s)] +``` + +### Types + +| Type | When to use | +|------|-------------| +| `feat` | A new feature (triggers a minor version bump) | +| `fix` | A bug fix (triggers a patch version bump) | +| `docs` | Documentation changes only | +| `chore` | Build process, dependency updates, tooling | +| `refactor` | Code change that neither fixes a bug nor adds a feature | +| `perf` | Performance improvement | +| `test` | Adding or updating tests | +| `ci` | CI/CD configuration changes | + +Append `!` after the type/scope for **breaking changes** (triggers a major version bump): + +``` +feat(api)!: remove deprecated /v0 endpoints +``` + +### Examples + +``` +feat(markets): add oracle result caching +fix(newsletter): handle duplicate subscription gracefully +docs(api): document rate-limit response headers +chore(deps): bump axum to 0.7.5 +``` + +--- + +## Pull Request Process + +1. Fork the repository and create your branch from `main`. +2. Ensure all tests pass locally (see [Running Tests](#running-tests)). +3. Keep commits focused — one logical change per commit. +4. Open a PR against `main` with a clear title following the commit convention. +5. Fill in the PR description: + - **What** changed and **why** + - How to test the change + - Any breaking changes or migration steps +6. Link related issues using `Closes #` in the PR description. +7. At least one approval is required before merging. +8. Squash-merge is preferred to keep the history clean. + +### PR Checklist + +- [ ] Branch is up to date with `main` +- [ ] Commit messages follow Conventional Commits +- [ ] Tests added or updated for the change +- [ ] Documentation updated if behaviour changed +- [ ] No secrets or credentials committed +- [ ] `CHANGELOG.md` **not** manually edited + +--- + +## Running Tests + +### API (Rust) + +```bash +cd services/api +cargo test +``` + +### Frontend (Next.js) + +```bash +cd frontend +npm test # unit tests (Jest) +npm run test:e2e # end-to-end tests (Playwright) +``` + +### TTS Service + +```bash +cd services/tts +npm test +``` + +### Smart Contracts + +```bash +cd contracts/predict-iq +make test +``` + +--- + +## Code Style + +### Rust + +- Follow `rustfmt` defaults — run `cargo fmt` before committing. +- Lint with `cargo clippy -- -D warnings`. + +### TypeScript / JavaScript + +- ESLint and Prettier are configured in the `frontend/` directory. +- Run `npm run lint` and `npm run format` before committing. + +### YAML / Markdown + +- Keep line length reasonable (80–100 characters). +- Use 2-space indentation for YAML. + +--- + +## Questions? + +Open an issue or start a discussion on [GitHub](https://github.com/solutions-plug/predictIQ/issues). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..658a883d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,69 @@ +# Security Policy + +## Supported Versions + +Only the latest release of PredictIQ receives security fixes. + +| Version | Supported | +|---------|-----------| +| Latest | ✅ Yes | +| Older | ❌ No | + +## Reporting a Vulnerability + +**Please do not open a public GitHub issue for security vulnerabilities.** + +Use one of the following channels: + +1. **GitHub Private Vulnerability Reporting** (preferred) — click the + [Report a vulnerability](../../security/advisories/new) button on the + Security tab of this repository. +2. **Email** — send details to `security@predictiq.io` with the subject line + `[SECURITY] `. + +### What to include + +- A clear description of the vulnerability and its potential impact +- Steps to reproduce or a proof-of-concept (if available) +- Affected component(s) and version(s) +- Any suggested mitigations + +## Response Timeline + +| Milestone | Target | +|-----------|--------| +| Acknowledgement | Within **2 business days** | +| Initial assessment | Within **5 business days** | +| Fix or mitigation | Within **30 days** for critical/high; **90 days** for medium/low | +| Public disclosure | After a fix is available and affected users have had time to update | + +We follow [coordinated disclosure](https://en.wikipedia.org/wiki/Coordinated_vulnerability_disclosure). We will notify you before any public disclosure and credit you in the release notes unless you prefer to remain anonymous. + +## Disclosure Policy + +- Vulnerabilities are kept confidential until a fix is released. +- We will publish a security advisory on GitHub after the fix is deployed. +- We ask reporters to refrain from public disclosure until we have released a fix or the agreed embargo period has passed. + +## Scope + +The following are **in scope**: + +- `services/api` — Rust API backend +- `services/tts` — TTS microservice +- `frontend` — Next.js frontend +- `contracts/predict-iq` — Soroban smart contracts +- CI/CD pipelines and infrastructure-as-code in this repository + +The following are **out of scope**: + +- Third-party services (SendGrid, Stellar network, Pyth Network) +- Denial-of-service attacks without a demonstrated security impact +- Issues already reported or known + +## Security Best Practices for Contributors + +- Never commit secrets, API keys, or credentials — use environment variables. +- Follow the principle of least privilege when adding new permissions. +- Validate and sanitise all external input. +- Keep dependencies up to date; dependency-scan CI runs on every PR. diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 00000000..7f5e37e1 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,48 @@ +[changelog] +header = """ +# Changelog + +All notable changes to this project will be documented in this file. +Generated automatically from conventional commits — **do not edit manually**. + +""" +body = """ +{% if version %}\ +## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ +## [Unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group | striptags | trim | upper_first }} +{% for commit in commits %} +- {% if commit.scope %}**{{ commit.scope }}:** {% endif %}{{ commit.message | upper_first }}\ +{% if commit.breaking %} (**BREAKING**){% endif %} ([`{{ commit.id | truncate(length=7, end="") }}`]({{ commit.id }}))\ +{% endfor %} +{% endfor %}\n +""" +trim = true +footer = "" + +[git] +conventional_commits = true +filter_unconventional = true +split_commits = false +commit_preprocessors = [] +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactoring" }, + { message = "^docs", group = "Documentation" }, + { message = "^test", group = "Testing" }, + { message = "^chore\\(deps\\)", group = "Dependencies" }, + { message = "^chore|^ci", group = "Miscellaneous" }, + { message = "^revert", group = "Reverts" }, +] +protect_breaking_commits = false +filter_commits = true +tag_pattern = "v[0-9].*" +skip_tags = "" +ignore_tags = "" +topo_order = false +sort_commits = "oldest" diff --git a/docs/README.md b/docs/README.md index d72c9774..143bf044 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,8 +21,9 @@ docs/ ### Want to Contribute? -1. **[API Specification](../API_SPEC.md)** - API reference and integration guide -2. **[Infrastructure README](../infrastructure/README.md)** - Infrastructure and deployment overview +1. **[Contributing Guide](../CONTRIBUTING.md)** - Setup, branch naming, commit conventions, and PR process +2. **[API Specification](../API_SPEC.md)** - API reference and integration guide +3. **[Infrastructure README](../infrastructure/README.md)** - Infrastructure and deployment overview ## 📖 Documentation Categories diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index dbab644c..71b1d904 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -32,11 +32,17 @@ interface RequestOptions { export class ApiError extends Error { /** HTTP status code, or 0 for network/connection failures. */ readonly status: number; + /** Machine-readable error code from the API (e.g. "NOT_FOUND", "RATE_LIMITED"). */ + readonly code: string; + /** Optional additional context returned by the API. */ + readonly details?: Record; - constructor(message: string, status: number) { + constructor(message: string, status: number, code = "UNKNOWN_ERROR", details?: Record) { super(message); this.name = "ApiError"; this.status = status; + this.code = code; + this.details = details; } /** True for client errors (4xx). */ @@ -95,7 +101,9 @@ async function request( if (!res.ok) { const err = await res.json().catch(() => ({ message: res.statusText })); const message = err?.message ?? `HTTP ${res.status}`; - throw new ApiError(message, res.status); + const code = err?.code ?? "UNKNOWN_ERROR"; + const details = err?.details ?? undefined; + throw new ApiError(message, res.status, code, details); } // 204 / empty body diff --git a/services/api/openapi.yaml b/services/api/openapi.yaml index 0ae5a97b..915e77f1 100644 --- a/services/api/openapi.yaml +++ b/services/api/openapi.yaml @@ -50,6 +50,8 @@ paths: schema: type: string example: ok + "500": + $ref: "#/components/responses/ApiError" /api/v1/statistics: get: @@ -65,6 +67,12 @@ paths: application/json: schema: $ref: "#/components/schemas/AnyObject" + "400": + $ref: "#/components/responses/ApiError" + "401": + $ref: "#/components/responses/ApiError" + "429": + $ref: "#/components/responses/ApiError" "500": $ref: "#/components/responses/ApiError" @@ -84,6 +92,10 @@ paths: type: array items: $ref: "#/components/schemas/FeaturedMarketView" + "400": + $ref: "#/components/responses/ApiError" + "429": + $ref: "#/components/responses/ApiError" "500": $ref: "#/components/responses/ApiError" @@ -103,6 +115,10 @@ paths: application/json: schema: $ref: "#/components/schemas/AnyObject" + "400": + $ref: "#/components/responses/ApiError" + "429": + $ref: "#/components/responses/ApiError" "500": $ref: "#/components/responses/ApiError" @@ -123,10 +139,16 @@ paths: application/json: schema: $ref: "#/components/schemas/InvalidationResult" + "400": + $ref: "#/components/responses/ApiError" "401": $ref: "#/components/responses/ApiError" "403": $ref: "#/components/responses/ApiError" + "404": + $ref: "#/components/responses/ApiError" + "429": + $ref: "#/components/responses/ApiError" "500": $ref: "#/components/responses/ApiError" @@ -153,14 +175,18 @@ paths: application/json: schema: $ref: "#/components/schemas/BlockchainHealth" + "400": + $ref: "#/components/responses/ApiError" + "429": + $ref: "#/components/responses/ApiError" + "500": + $ref: "#/components/responses/ApiError" "503": description: Degraded (node up, contract unreachable) or unhealthy (node down) content: application/json: schema: $ref: "#/components/schemas/BlockchainHealth" - "500": - $ref: "#/components/responses/ApiError" /api/v1/blockchain/markets/{market_id}: get: @@ -177,6 +203,12 @@ paths: application/json: schema: $ref: "#/components/schemas/AnyObject" + "400": + $ref: "#/components/responses/ApiError" + "404": + $ref: "#/components/responses/ApiError" + "429": + $ref: "#/components/responses/ApiError" "500": $ref: "#/components/responses/ApiError" @@ -194,6 +226,10 @@ paths: application/json: schema: $ref: "#/components/schemas/AnyObject" + "400": + $ref: "#/components/responses/ApiError" + "429": + $ref: "#/components/responses/ApiError" "500": $ref: "#/components/responses/ApiError" @@ -219,6 +255,12 @@ paths: application/json: schema: $ref: "#/components/schemas/AnyObject" + "400": + $ref: "#/components/responses/ApiError" + "404": + $ref: "#/components/responses/ApiError" + "429": + $ref: "#/components/responses/ApiError" "500": $ref: "#/components/responses/ApiError" @@ -237,6 +279,12 @@ paths: application/json: schema: $ref: "#/components/schemas/AnyObject" + "400": + $ref: "#/components/responses/ApiError" + "404": + $ref: "#/components/responses/ApiError" + "429": + $ref: "#/components/responses/ApiError" "500": $ref: "#/components/responses/ApiError" @@ -259,6 +307,12 @@ paths: application/json: schema: $ref: "#/components/schemas/AnyObject" + "400": + $ref: "#/components/responses/ApiError" + "404": + $ref: "#/components/responses/ApiError" + "429": + $ref: "#/components/responses/ApiError" "500": $ref: "#/components/responses/ApiError" @@ -285,6 +339,8 @@ paths: $ref: "#/components/responses/NewsletterResponse" "429": $ref: "#/components/responses/NewsletterResponse" + "500": + $ref: "#/components/responses/ApiError" /api/v1/newsletter/confirm: get: @@ -304,6 +360,8 @@ paths: $ref: "#/components/responses/NewsletterResponse" "404": $ref: "#/components/responses/NewsletterResponse" + "500": + $ref: "#/components/responses/ApiError" /api/v1/newsletter/unsubscribe: delete: @@ -321,6 +379,10 @@ paths: $ref: "#/components/responses/NewsletterResponse" "400": $ref: "#/components/responses/NewsletterResponse" + "404": + $ref: "#/components/responses/NewsletterResponse" + "500": + $ref: "#/components/responses/ApiError" /api/v1/newsletter/gdpr/export: get: @@ -345,6 +407,8 @@ paths: $ref: "#/components/responses/NewsletterResponse" "404": $ref: "#/components/responses/NewsletterResponse" + "500": + $ref: "#/components/responses/ApiError" /api/v1/newsletter/gdpr/delete: delete: @@ -362,6 +426,10 @@ paths: $ref: "#/components/responses/NewsletterResponse" "400": $ref: "#/components/responses/NewsletterResponse" + "404": + $ref: "#/components/responses/NewsletterResponse" + "500": + $ref: "#/components/responses/ApiError" /api/v1/email/preview/{template_name}: get: @@ -388,6 +456,16 @@ paths: application/json: schema: $ref: "#/components/schemas/AnyObject" + "400": + $ref: "#/components/responses/ApiError" + "401": + $ref: "#/components/responses/ApiError" + "403": + $ref: "#/components/responses/ApiError" + "404": + $ref: "#/components/responses/ApiError" + "429": + $ref: "#/components/responses/ApiError" "500": $ref: "#/components/responses/ApiError" @@ -411,6 +489,14 @@ paths: application/json: schema: $ref: "#/components/schemas/EmailTestResponse" + "400": + $ref: "#/components/responses/ApiError" + "401": + $ref: "#/components/responses/ApiError" + "403": + $ref: "#/components/responses/ApiError" + "429": + $ref: "#/components/responses/ApiError" "500": $ref: "#/components/responses/ApiError" @@ -442,6 +528,14 @@ paths: application/json: schema: $ref: "#/components/schemas/AnyObject" + "400": + $ref: "#/components/responses/ApiError" + "401": + $ref: "#/components/responses/ApiError" + "403": + $ref: "#/components/responses/ApiError" + "429": + $ref: "#/components/responses/ApiError" "500": $ref: "#/components/responses/ApiError" @@ -459,6 +553,14 @@ paths: application/json: schema: $ref: "#/components/schemas/AnyObject" + "400": + $ref: "#/components/responses/ApiError" + "401": + $ref: "#/components/responses/ApiError" + "403": + $ref: "#/components/responses/ApiError" + "429": + $ref: "#/components/responses/ApiError" "500": $ref: "#/components/responses/ApiError" @@ -531,6 +633,10 @@ paths: "401": description: Signature verification failed (not authorized as SendGrid) $ref: "#/components/responses/ApiError" + "429": + $ref: "#/components/responses/ApiError" + "500": + $ref: "#/components/responses/ApiError" components: parameters: @@ -624,6 +730,11 @@ components: message: type: string description: Human-readable error description + details: + type: object + additionalProperties: true + nullable: true + description: Optional additional context about the error FeaturedMarketView: type: object