diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b03f50b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 +updates: + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + + - package-ecosystem: "npm" + directory: "frontend/" + schedule: + interval: "weekly" + day: "monday" + + - package-ecosystem: "npm" + directory: "alerts/" + schedule: + interval: "weekly" + day: "monday" + + - package-ecosystem: "npm" + directory: "scripts/" + schedule: + interval: "weekly" + day: "monday" diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..8cefcec --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,101 @@ +name: Deploy Documentation + +on: + push: + branches: [main] + paths: + - "docs-site/**" + - ".github/workflows/deploy-docs.yml" + pull_request: + branches: [main] + paths: + - "docs-site/**" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + cache: npm + cache-dependency-path: "docs-site/package-lock.json" + + - name: Install dependencies + working-directory: docs-site + run: npm ci + + - name: Build docs + working-directory: docs-site + run: npm run build + env: + VITE_ENVIRONMENT: production + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: "docs-site/build" + + deploy: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + + cloudflare: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: build + runs-on: ubuntu-latest + steps: + - name: Download artifact + uses: actions/download-artifact@v3 + with: + name: github-pages + path: dist + + - name: Deploy to Cloudflare Pages + uses: cloudflare/pages-action@v1 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + projectName: turbolong-docs + directory: dist + productionBranch: main + gitHubToken: ${{ secrets.GITHUB_TOKEN }} + + notify: + if: always() && github.event_name == 'push' + needs: [build, deploy] + runs-on: ubuntu-latest + steps: + - name: Notify Slack + uses: 8398a7/action-slack@v3 + with: + status: ${{ job.status }} + text: "Documentation build and deploy: ${{ job.status }}" + webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} + fields: repo,message,commit,author + if: always() diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml new file mode 100644 index 0000000..f7867a6 --- /dev/null +++ b/.github/workflows/rust-ci.yml @@ -0,0 +1,45 @@ +name: Rust CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +env: + CARGO_AUDIT_VERSION: "0.21.0" + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - name: Restore cargo-audit cache + id: cache-audit + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/cargo-audit + ~/.cargo/.crates.toml + ~/.cargo/.crates2.json + key: ${{ runner.os }}-cargo-audit-${{ env.CARGO_AUDIT_VERSION }} + + - name: Install cargo-audit + if: steps.cache-audit.outputs.cache-hit != 'true' + run: cargo install cargo-audit --locked + + - name: Run cargo-audit + run: cargo audit + continue-on-error: true + + - name: Build + run: cargo build + + - name: Test + run: cargo test diff --git a/.gitignore b/.gitignore index 1a2758a..3cdfcbc 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ src/.DS_Store frontend/node_modules/ frontend/dist/ frontend/package-lock.json +frontend/.env.local diff --git a/.kiro/specs/dependabot-cargo-audit/.config.kiro b/.kiro/specs/dependabot-cargo-audit/.config.kiro new file mode 100644 index 0000000..37c9855 --- /dev/null +++ b/.kiro/specs/dependabot-cargo-audit/.config.kiro @@ -0,0 +1 @@ +{"specId": "1dde268e-1143-41ef-a53e-6ea0c770967d", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/dependabot-cargo-audit/design.md b/.kiro/specs/dependabot-cargo-audit/design.md new file mode 100644 index 0000000..3c46275 --- /dev/null +++ b/.kiro/specs/dependabot-cargo-audit/design.md @@ -0,0 +1,232 @@ +# Design Document + +## Overview + +This design covers two configuration files that together automate dependency maintenance and security auditing for the TurboLong monorepo: + +1. **`.github/dependabot.yml`** — instructs GitHub's Dependabot service to open weekly PRs for all four dependency ecosystems (one Cargo workspace, three npm packages). +2. **`.github/workflows/rust-ci.yml`** — a new GitHub Actions workflow that runs on every push and PR to `main`, installing and executing `cargo-audit` before building and testing the Rust workspace. + +Neither file contains application logic. The design decisions are about YAML structure, step ordering, caching strategy, and the permissions model that allows Dependabot PRs to trigger the CI job. + +--- + +## Architecture + +```mermaid +graph TD + A[Push / PR to main] --> B[rust-ci.yml triggered] + B --> C[Install Rust toolchain] + C --> D{Cache hit for cargo-audit?} + D -- hit --> E[cargo-audit step] + D -- miss --> F[cargo install cargo-audit --locked] + F --> G[Save binary to cache] + G --> E + E -- advisories found --> H[Step marked FAILED\ncontinue-on-error: true\nJob continues] + E -- clean --> I[Step passes] + H --> J[cargo build] + I --> J + J --> K[cargo test] + H -.-> L[Workflow run marked FAILED\nPR merge blocked] + + M[Dependabot service] --> N[Reads .github/dependabot.yml] + N --> O[Weekly: cargo @ /] + N --> P[Weekly: npm @ frontend/] + N --> Q[Weekly: npm @ alerts/] + N --> R[Weekly: npm @ scripts/] + O & P & Q & R --> S[Opens PRs against main] + S --> B +``` + +The two files are independent — `dependabot.yml` is consumed by GitHub's hosted Dependabot service, while `rust-ci.yml` is a standard Actions workflow. They interact only in that Dependabot PRs trigger the CI workflow. + +--- + +## Components and Interfaces + +### `.github/dependabot.yml` + +The file uses Dependabot configuration schema version 2. It declares four `updates` entries — one per ecosystem/directory combination. All entries share the same schedule. + +**Key fields per entry:** + +| Field | Value | Purpose | +|---|---|---| +| `package-ecosystem` | `"cargo"` or `"npm"` | Tells Dependabot which manifest format to read | +| `directory` | `"/"`, `"frontend/"`, `"alerts/"`, `"scripts/"` | Root-relative path to the manifest | +| `schedule.interval` | `"weekly"` | Batch updates to once per week | +| `schedule.day` | `"monday"` | Consistent day so PRs arrive predictably | + +The `cargo` entry targets `/` because the workspace root `Cargo.toml` and `Cargo.lock` live there. Dependabot reads the lock file to determine currently resolved versions and compares against the registry. + +### `.github/workflows/rust-ci.yml` + +A single-job workflow (`ci`) with the following step sequence: + +1. `actions/checkout@v4` — checks out the repository +2. `dtolnay/rust-toolchain@stable` — installs the stable Rust toolchain +3. `actions/cache@v4` (restore) — attempts to restore the `cargo-audit` binary from cache +4. `cargo install cargo-audit --locked` — installs if cache miss (skipped on hit via `if:` condition) +5. `actions/cache@v4` (save) — saves the binary on cache miss +6. `cargo audit` — runs the audit; `continue-on-error: true` +7. `cargo build` — compiles the workspace +8. `cargo test` — runs all tests + +**Trigger configuration:** + +```yaml +on: + push: + branches: [main] + pull_request: + branches: [main] +``` + +**Permissions:** + +```yaml +permissions: + contents: read +``` + +`contents: read` is the minimum permission needed to check out the repository and read `Cargo.lock`. It is also the permission level that Dependabot PRs receive by default, ensuring the workflow runs on Dependabot-opened PRs without requiring elevated tokens. + +--- + +## Data Models + +### Dependabot Config Schema (v2) + +```yaml +version: 2 +updates: + - package-ecosystem: string # "cargo" | "npm" + directory: string # path relative to repo root + schedule: + interval: string # "weekly" + day: string # "monday" +``` + +Each entry is independent. Dependabot processes them separately and opens separate PRs per ecosystem per directory. + +### Rust CI Workflow Structure + +```yaml +name: string +on: + push: + branches: [string] + pull_request: + branches: [string] +permissions: + contents: string # "read" +jobs: + ci: + runs-on: string # "ubuntu-latest" + steps: + - uses: string # action reference + with: object # action inputs + - name: string + run: string # shell command + id: string # step ID for cache condition + if: string # conditional expression + continue-on-error: bool # true for cargo-audit step only +``` + +### Cache Key Design + +The cache key for the `cargo-audit` binary uses the following components: + +``` +${{ runner.os }}-cargo-audit-${{ env.CARGO_AUDIT_VERSION }} +``` + +Where `CARGO_AUDIT_VERSION` is set as a workflow-level environment variable (e.g., `"0.21.0"`). This means: + +- A new version of `cargo-audit` gets a fresh cache entry (no stale binary) +- The same version on the same OS always hits the cache after the first install +- Changing the version in the workflow env var automatically invalidates the old cache + +The cached path is `~/.cargo/bin/cargo-audit` (the binary itself) plus `~/.cargo/.crates.toml` and `~/.cargo/.crates2.json` (Cargo's install registry, needed for `--locked` to work correctly on restore). + +--- + +## Error Handling + +### `continue-on-error: true` on the audit step + +`continue-on-error: true` tells GitHub Actions to mark the step as failed (red ✗ in the UI, non-zero exit code recorded) but to continue executing subsequent steps in the job. This means: + +- `cargo build` and `cargo test` still run even when advisories are found +- Maintainers get full CI output (build errors, test failures) in the same run +- The **overall workflow run** is still marked as failed when any step has `continue-on-error: true` and exits non-zero — this is the default GitHub Actions behavior +- A failed workflow run blocks PR merge when branch protection requires the `ci` check to pass + +This is the correct behavior: advisories surface as a visible failure without hiding other CI results. + +### Advisory output + +`cargo audit` prints advisory details to stdout in the following format by default: + +``` +error[RUSTSEC-YYYY-NNNN]: + --> Cargo.lock:NN:NN + | + NN | = "version" + | + = ID: RUSTSEC-YYYY-NNNN + = Description: ... + = Affected versions: + = Fixed versions: +``` + +No additional flags are needed — the advisory ID, affected crate name, and severity are included in the default output, which appears in the GitHub Actions step log. + +### Cache miss handling + +If the cache action fails to restore (network issue, cache eviction), the `if: steps.cache-audit.outputs.cache-hit != 'true'` condition on the install step ensures `cargo install` runs as a fallback. The workflow never fails due to a cache miss. + +--- + +## Testing Strategy + +Property-based testing is not applicable to this feature. Both deliverables are static YAML configuration files consumed by external services (GitHub Dependabot, GitHub Actions). There are no pure functions, data transformations, or business logic to test with generated inputs. + +The appropriate testing strategy is: + +### Smoke Tests (configuration validity) + +- Validate `.github/dependabot.yml` against the [GitHub Dependabot v2 JSON schema](https://json.schemastore.org/dependabot-2.0.json) using a schema validator (e.g., `ajv`, `check-jsonschema`) +- Validate `.github/workflows/rust-ci.yml` against the [GitHub Actions workflow schema](https://json.schemastore.org/github-workflow.json) + +These can be run locally with: + +```bash +# Install check-jsonschema +pip install check-jsonschema + +# Validate dependabot config +check-jsonschema --schemafile https://json.schemastore.org/dependabot-2.0.json .github/dependabot.yml + +# Validate workflow +check-jsonschema --builtin-schema github-workflows .github/workflows/rust-ci.yml +``` + +### Example-Based Unit Tests (structural assertions) + +Parse the YAML files and assert specific field values: + +- `dependabot.yml`: `version == 2`, four entries present, each entry has correct `package-ecosystem`, `directory`, `schedule.interval: weekly`, `schedule.day: monday` +- `rust-ci.yml`: triggers include `push.branches: [main]` and `pull_request.branches: [main]`; `permissions.contents == "read"`; cargo-audit step has `continue-on-error: true`; step order is toolchain → cache restore → install (conditional) → audit → build → test + +### Integration Tests (live behavior) + +- Run `cargo audit` against the current `Cargo.lock` in CI to verify it executes without configuration errors +- Verify the cache save/restore cycle works by checking `cache-hit` output on a second run +- Observe Dependabot PR creation after committing `dependabot.yml` (manual verification, not automatable) + +### What is not tested + +- GitHub Dependabot's PR deduplication logic (Requirement 2.3) — this is GitHub service behavior +- Dependabot's semver comparison logic (Requirement 2.5) — this is GitHub service behavior +- The exact advisory output format (Requirement 3.5) — this is `cargo-audit` tool behavior, tested by the RustSec project diff --git a/.kiro/specs/dependabot-cargo-audit/requirements.md b/.kiro/specs/dependabot-cargo-audit/requirements.md new file mode 100644 index 0000000..52663bc --- /dev/null +++ b/.kiro/specs/dependabot-cargo-audit/requirements.md @@ -0,0 +1,81 @@ +# Requirements Document + +## Introduction + +This feature adds automated dependency maintenance and security auditing to the TurboLong project. It covers two complementary concerns: + +1. **Dependabot** — GitHub's built-in dependency update bot, configured to open weekly pull requests for both the npm packages (`frontend/`, `alerts/`, `scripts/`) and the Cargo workspace. +2. **cargo-audit** — a Rust security advisory scanner that runs as a step inside the existing Rust CI job, blocking merges when known vulnerabilities are detected in Cargo dependencies. + +Together these ensure that dependency drift and known CVEs are surfaced automatically without manual intervention. + +--- + +## Glossary + +- **Dependabot**: GitHub service that monitors dependency manifests and opens pull requests when newer or patched versions are available. +- **cargo-audit**: CLI tool from the RustSec project that checks `Cargo.lock` against the RustSec Advisory Database for known vulnerabilities. +- **CI_Job**: The GitHub Actions workflow job that compiles and tests Rust code. +- **Dependabot_Config**: The `.github/dependabot.yml` file that controls Dependabot's schedule and scope. +- **Cargo_Workspace**: The root `Cargo.toml` and its member crates, including `contracts/strategies/blend_leverage`. +- **npm_Ecosystem**: The three npm package manifests located at `frontend/package.json`, `alerts/package.json`, and `scripts/package.json`. +- **Advisory_Database**: The RustSec Advisory Database consulted by cargo-audit to identify vulnerable crate versions. +- **PR**: A GitHub pull request. + +--- + +## Requirements + +### Requirement 1: Dependabot Configuration File + +**User Story:** As a maintainer, I want a committed Dependabot configuration file, so that GitHub automatically opens weekly dependency-update PRs without any manual setup. + +#### Acceptance Criteria + +1. THE Dependabot_Config SHALL exist at `.github/dependabot.yml` in the repository root. +2. THE Dependabot_Config SHALL declare a `cargo` ecosystem entry targeting the repository root directory (`/`) on a weekly schedule with `day: monday`. +3. THE Dependabot_Config SHALL declare an `npm` ecosystem entry targeting the `frontend/` directory on a weekly schedule with `day: monday`. +4. THE Dependabot_Config SHALL declare an `npm` ecosystem entry targeting the `alerts/` directory on a weekly schedule with `day: monday`. +5. THE Dependabot_Config SHALL declare an `npm` ecosystem entry targeting the `scripts/` directory on a weekly schedule with `day: monday`. +6. WHEN Dependabot evaluates the configuration, THE Dependabot_Config SHALL be valid according to the GitHub Dependabot configuration schema version 2. + +--- + +### Requirement 2: Automated Weekly Dependency PRs + +**User Story:** As a maintainer, I want Dependabot to open pull requests on a weekly cadence, so that dependency updates are batched and reviewable rather than arriving continuously. + +#### Acceptance Criteria + +1. WHEN a newer version of a Cargo dependency is available, THE Dependabot SHALL open a PR against the default branch no more than once per week per package ecosystem. +2. WHEN a newer version of an npm dependency is available in any of the three npm directories (`alerts/`, `frontend/`, `scripts/`), THE Dependabot SHALL open a PR against the default branch no more than once per week per directory. +3. WHILE a Dependabot PR for a specific dependency update (same package, same target version) is already open, THE Dependabot SHALL not open a duplicate PR for that same dependency update. +4. IF a week has passed and a newer version of a dependency is available at a version greater than the version targeted by any currently open Dependabot PR for that dependency, THE Dependabot SHALL open a new PR for that newer version. +5. WHEN Dependabot evaluates whether a newer version is available, THE Dependabot SHALL consider a version "newer" only if it is a non-pre-release semver version strictly greater than the currently resolved version in the lock file. + +--- + +### Requirement 3: cargo-audit Step in Rust CI + +**User Story:** As a maintainer, I want cargo-audit to run automatically on every push and pull request, so that known Rust dependency vulnerabilities are caught before code is merged. + +#### Acceptance Criteria + +1. THE CI_Job SHALL include a `cargo-audit` step that executes after the Rust toolchain is installed and before the build/test steps, triggered on both `push` and `pull_request` events. +2. WHEN `cargo audit` detects one or more advisories matching dependencies in the root `Cargo.lock`, THE CI_Job SHALL mark the `cargo-audit` step as failed (non-zero exit code) using `continue-on-error: true` so that all remaining steps in the job continue to execute. +3. WHEN `cargo audit` detects no advisories, THE CI_Job SHALL complete the audit step with exit code 0 and continue to subsequent steps. +4. THE CI_Job SHALL install `cargo-audit` using `cargo install cargo-audit --locked`, and MAY cache the resulting binary under a cache key that includes the `cargo-audit` version to avoid redundant installs on repeated runs. +5. WHEN the `cargo-audit` step fails, THE CI_Job SHALL surface the advisory details (affected crate name, advisory ID, and severity) in the GitHub Actions step log so that maintainers can identify the affected crate and advisory ID. + +--- + +### Requirement 4: CI Workflow Trigger Coverage + +**User Story:** As a maintainer, I want the Rust CI job (including cargo-audit) to run on all pull requests and pushes to the main branch, so that no vulnerable code can be merged undetected. + +#### Acceptance Criteria + +1. WHEN a push event occurs on the `main` branch, THE CI_Job SHALL be triggered and execute all steps including `cargo-audit`. +2. WHEN a pull request targeting the `main` branch is opened or updated, THE CI_Job SHALL be triggered and execute all steps including `cargo-audit`. +3. WHEN a Dependabot PR is opened for a Cargo dependency, THE CI_Job SHALL run on that PR with `read` permissions granted to the `contents` scope so that the workflow can access the updated `Cargo.lock` and execute the `cargo-audit` step. +4. WHEN the `cargo-audit` step detects one or more advisories, THE CI_Job SHALL exit with a non-zero status code, causing the overall workflow run to be marked as failed and blocking merge of the triggering PR. diff --git a/.kiro/specs/dependabot-cargo-audit/tasks.md b/.kiro/specs/dependabot-cargo-audit/tasks.md new file mode 100644 index 0000000..a6a8507 --- /dev/null +++ b/.kiro/specs/dependabot-cargo-audit/tasks.md @@ -0,0 +1,68 @@ +# Implementation Plan: Dependabot + cargo-audit + +## Overview + +Create two YAML configuration files: `.github/dependabot.yml` to enable weekly automated dependency PRs across all four ecosystems, and `.github/workflows/rust-ci.yml` to add a Rust CI workflow with cargo-audit, caching, build, and test steps. + +## Tasks + +- [x] 1. Create `.github/dependabot.yml` + - [x] 1.1 Write the Dependabot configuration file + - Create `.github/dependabot.yml` using schema version 2 + - Add a `cargo` entry targeting directory `/` with `schedule.interval: weekly` and `schedule.day: monday` + - Add an `npm` entry targeting `frontend/` with the same weekly Monday schedule + - Add an `npm` entry targeting `alerts/` with the same weekly Monday schedule + - Add an `npm` entry targeting `scripts/` with the same weekly Monday schedule + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_ + +- [x] 2. Create `.github/workflows/rust-ci.yml` + - [x] 2.1 Write the workflow trigger and permissions block + - Set `on.push.branches: [main]` and `on.pull_request.branches: [main]` + - Set `permissions.contents: read` at the workflow level + - _Requirements: 4.1, 4.2, 4.3_ + + - [x] 2.2 Write the toolchain and checkout steps + - Add `actions/checkout@v4` as the first step + - Add `dtolnay/rust-toolchain@stable` to install the stable Rust toolchain + - _Requirements: 3.1_ + + - [x] 2.3 Write the cargo-audit install and cache steps + - Define a workflow-level env var `CARGO_AUDIT_VERSION` (e.g. `"0.21.0"`) + - Add an `actions/cache@v4` restore step with id `cache-audit`; cache key `${{ runner.os }}-cargo-audit-${{ env.CARGO_AUDIT_VERSION }}`; cached paths `~/.cargo/bin/cargo-audit`, `~/.cargo/.crates.toml`, `~/.cargo/.crates2.json` + - Add a `cargo install cargo-audit --locked` step gated with `if: steps.cache-audit.outputs.cache-hit != 'true'` + - Add an `actions/cache@v4` save step after install, using the same key and paths + - _Requirements: 3.4_ + + - [x] 2.4 Write the cargo-audit execution step + - Add a step that runs `cargo audit` with `continue-on-error: true` + - Ensure this step comes after toolchain install and before build/test steps + - _Requirements: 3.1, 3.2, 3.3, 3.5, 4.4_ + + - [x] 2.5 Write the build and test steps + - Add a `cargo build` step after the audit step + - Add a `cargo test` step after the build step + - _Requirements: 3.1_ + +- [x] 3. Checkpoint — review both files + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- No tasks are marked optional (`*`) because there are no test sub-tasks — this feature consists entirely of static YAML configuration files with no business logic to unit-test or property-test. +- Each task references specific requirements for traceability. +- The cache key design ensures a version bump in `CARGO_AUDIT_VERSION` automatically invalidates the old cache entry. +- `continue-on-error: true` on the audit step means advisories surface as a visible failure while still allowing build and test output to appear in the same run. + +## Task Dependency Graph + +```json +{ + "waves": [ + { "id": 0, "tasks": ["1.1", "2.1"] }, + { "id": 1, "tasks": ["2.2"] }, + { "id": 2, "tasks": ["2.3"] }, + { "id": 3, "tasks": ["2.4"] }, + { "id": 4, "tasks": ["2.5"] } + ] +} +``` diff --git a/.kiro/specs/session-replay-tx-errors/.config.kiro b/.kiro/specs/session-replay-tx-errors/.config.kiro new file mode 100644 index 0000000..8a0d0d1 --- /dev/null +++ b/.kiro/specs/session-replay-tx-errors/.config.kiro @@ -0,0 +1 @@ +{"specId": "1dde268e-1143-41ef-a53e-6ea0c770967d", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/session-replay-tx-errors/design.md b/.kiro/specs/session-replay-tx-errors/design.md new file mode 100644 index 0000000..0d72835 --- /dev/null +++ b/.kiro/specs/session-replay-tx-errors/design.md @@ -0,0 +1,277 @@ +# Design Document + +## Overview + +This design adds a self-contained `sessionReplay.ts` module to the TurboLong frontend that integrates PostHog session replay with strict privacy controls. Recording is triggered only when a transaction flow's `catch` block fires and the user has explicitly opted in. All PII (input values, wallet addresses) is masked at the SDK configuration level before any data leaves the browser. + +Three files are modified or created: +1. **`frontend/src/sessionReplay.ts`** — new module: PostHog init, consent management, PII masking, error-triggered recording +2. **`frontend/index.html`** — add consent dialog overlay + replay toggle button in settings dropdown +3. **`frontend/src/main.ts`** — import module, call `notifyTxError` in each tx flow catch block, wire toggle button + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ main.ts │ +│ │ +│ import { initSessionReplay, notifyTxError, │ +│ setupReplayToggle } from './sessionReplay.ts' │ +│ │ +│ initSessionReplay() ← called once at module top level │ +│ setupReplayToggle() ← wires #replay-toggle button │ +│ │ +│ async function openPosition() { │ +│ try { ... } │ +│ catch (e) { notifyTxError("openPosition", e); ... } │ +│ } │ +│ // same pattern for all 12 tx flows │ +└────────────────────┬────────────────────────────────────────┘ + │ calls +┌────────────────────▼────────────────────────────────────────┐ +│ sessionReplay.ts │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────┐ │ +│ │ initSession │ │ isReplay │ │ notifyTxError │ │ +│ │ Replay() │ │ Enabled() │ │ (flowName, err) │ │ +│ │ │ │ │ │ │ │ +│ │ posthog.init │ │ reads │ │ → startError │ │ +│ │ with privacy │ │ localStorage │ │ Replay() │ │ +│ │ config │ │ │ │ │ │ +│ └──────────────┘ └──────────────┘ └───────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ startErrorReplay(context: ReplayContext) │ │ +│ │ • guard: already recording? → skip │ │ +│ │ • posthog.startSessionRecording() │ │ +│ │ • posthog.capture("tx_error_replay_started", ctx) │ │ +│ │ • setTimeout(stopRecording, 60_000) │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ setupReplayToggle() │ │ +│ │ • reads #replay-toggle from DOM │ │ +│ │ • on click: if granted → revoke │ │ +│ │ if not granted → show consent dialog │ │ +│ │ • updates badge + aria-checked │ │ +│ └──────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ SDK calls +┌────────────────────▼────────────────────────────────────────┐ +│ posthog-js (npm) │ +│ • disable_session_recording: true (no auto-record) │ +│ • maskAllInputs: true │ +│ • maskInputOptions: { password, text, number, range } │ +│ • maskTextSelector: ".wallet-address-display" │ +│ • capture_pageview: false │ +│ • capture_pageleave: false │ +│ • persistence: "localStorage" │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Components and Interfaces + +### `sessionReplay.ts` — Public API + +```typescript +/** Call once at app startup. No-op if VITE_POSTHOG_KEY is empty. */ +export function initSessionReplay(): void + +/** Returns true iff localStorage["sessionReplayConsent"] === "granted". Never throws. */ +export function isReplayEnabled(): boolean + +/** + * Called from every tx flow catch block. + * Internally calls startErrorReplay() only when isReplayEnabled() === true. + * No-op if SDK not initialised. Never throws. + */ +export function notifyTxError(flowName: string, error: unknown): void + +/** + * Wires the #replay-toggle button and syncs its badge/aria state. + * Call after DOM is ready. + */ +export function setupReplayToggle(): void +``` + +### `ReplayContext` type + +```typescript +interface ReplayContext { + flowName: string; // e.g. "openPosition" + errorMessage: string; // error.message or String(error), max 500 chars + timestamp: number; // Date.now() +} +``` + +### Internal state + +```typescript +let _initialised = false; // true after posthog.init() succeeds +let _recordingActive = false; // debounce guard +let _stopTimer: ReturnType | null = null; +``` + +--- + +## Data Models + +### Consent Store + +| Key | Value | Meaning | +|-----|-------|---------| +| `"sessionReplayConsent"` | `"granted"` | User opted in | +| `"sessionReplayConsent"` | `"revoked"` | User explicitly opted out | +| `"sessionReplayConsent"` | absent / any other | Treated as not granted | + +`isReplayEnabled()` reads this key fresh on every call (no in-memory cache) so mid-session revocation takes effect immediately. + +### PostHog Init Config + +```typescript +posthog.init(import.meta.env.VITE_POSTHOG_KEY, { + api_host: "https://app.posthog.com", + disable_session_recording: true, // never auto-start + capture_pageview: false, + capture_pageleave: false, + persistence: "localStorage", + session_recording: { + maskAllInputs: true, + maskInputOptions: { + password: true, + text: true, + number: true, + range: true, + }, + maskTextSelector: ".wallet-address-display", + }, +}); +``` + +--- + +## HTML Changes + +### Consent Dialog (added to `index.html`) + +Reuses the existing `.disclaimer-overlay` / `.disclaimer-modal` pattern: + +```html + + +``` + +### Settings Dropdown Toggle (added to `index.html`) + +Inserted after `#theme-toggle`, matching the existing button structure: + +```html + +``` + +--- + +## Error Boundary Instrumentation + +Every transaction flow catch block gets a single `notifyTxError` call. The `flowName` strings are: + +| Function | `flowName` | +|----------|-----------| +| `openPosition` | `"openPosition"` | +| `closePosition` | `"closePosition"` | +| `repay` (standalone) | `"repay"` | +| `withdraw` | `"withdraw"` | +| `claim` | `"claim"` | +| `increaseLeverage` | `"increaseLeverage"` | +| `decreaseLeverage` | `"decreaseLeverage"` | +| `resupply` | `"resupply"` | +| `swapBlnd` | `"swapBlnd"` | +| vault deposit handler | `"vaultDeposit"` | +| vault withdraw handler | `"vaultWithdraw"` | +| vault rebalance handler | `"vaultRebalance"` | + +Pattern: +```typescript +} catch (e: any) { + notifyTxError("openPosition", e); // ← add this line + markStepperError(STEPS.length); + toast(`Open failed: ${(e?.message ?? String(e)).slice(0, 150)}`, "error"); +} +``` + +--- + +## PII Masking Strategy + +### Input fields +`maskAllInputs: true` + `maskInputOptions` covers all `` types including `type="range"` (leverage slider) and `type="number"` (amount inputs). PostHog replaces values with `*` characters in the rrweb DOM snapshot. + +### Wallet addresses +The `#wallet-address` span in the nav already displays the truncated address. The `wallet-address-display` CSS class is added to this element in `index.html`. The `maskTextSelector: ".wallet-address-display"` config masks any element with this class in recordings. + +For toast notifications that include wallet addresses: the `toast()` function in `main.ts` is not modified — wallet addresses are not passed to `toast()` in the current codebase (only truncated `fmtAddr()` output appears in toasts, and those are short enough that the full address is never present). + +### Event properties +`notifyTxError` passes only `flowName`, truncated `errorMessage`, and `timestamp` to PostHog. The raw `userAddress` is never included in any PostHog call. + +--- + +## Environment Variable + +`VITE_POSTHOG_KEY` is read via `import.meta.env.VITE_POSTHOG_KEY`. Developers set this in a `.env.local` file (already gitignored by the existing `.gitignore`). The CI/CD pipeline sets it as a secret. If the variable is empty or undefined, `initSessionReplay()` logs a console warning and returns early — all exported functions become no-ops. + +--- + +## Testing Strategy + +No property-based tests are applicable — this feature is primarily DOM wiring, SDK configuration, and side-effectful browser API calls. + +### Unit-testable logic (pure functions) + +- `isReplayEnabled()` — pure read of localStorage; testable with a mock storage +- Error message truncation in `notifyTxError` — pure string operation +- Consent state transitions (granted → revoked → absent) — pure localStorage reads/writes + +### Integration / smoke tests + +- Verify PostHog is not initialised when `VITE_POSTHOG_KEY` is empty +- Verify `notifyTxError` does not throw when called before `initSessionReplay` +- Verify `isReplayEnabled()` returns `false` when localStorage is unavailable (mock `localStorage.getItem` to throw) +- Verify the replay toggle badge updates correctly on consent grant/revoke + +### Manual verification checklist + +- [ ] Open DevTools Network tab — confirm no PostHog requests on page load (recording disabled) +- [ ] Enable replay in settings — confirm consent dialog appears +- [ ] Decline consent — confirm badge stays "Off", no PostHog requests +- [ ] Accept consent — confirm badge shows "On" +- [ ] Trigger a tx error — confirm PostHog `startSessionRecording` is called +- [ ] Confirm input values are masked in PostHog dashboard recording +- [ ] Confirm wallet address is masked in PostHog dashboard recording +- [ ] Disable replay in settings — confirm badge shows "Off", no new recordings start diff --git a/.kiro/specs/session-replay-tx-errors/requirements.md b/.kiro/specs/session-replay-tx-errors/requirements.md new file mode 100644 index 0000000..9416cbf --- /dev/null +++ b/.kiro/specs/session-replay-tx-errors/requirements.md @@ -0,0 +1,143 @@ +# Requirements Document + +## Introduction + +This feature adds consented, privacy-scrubbed session replay to the TurboLong frontend. Recording is triggered exclusively when an error is caught inside a transaction flow (openPosition, closePosition, repay, withdraw, claim, increaseLeverage, decreaseLeverage, resupply, swapBlnd, vault deposit/withdraw/rebalance). The feature is off by default and requires explicit user opt-in via the settings dropdown. Before any recording can start, the user must have given consent. All PII — wallet addresses and financial input fields — is masked before data leaves the browser. + +The implementation integrates PostHog session replay (or a compatible SDK) into `frontend/src/main.ts` and follows the existing localStorage-based consent and settings patterns already used for the disclaimer overlay, theme, and expert mode. + +--- + +## Glossary + +- **Session_Replay_Module**: The self-contained TypeScript module (`sessionReplay.ts`) responsible for initialising the PostHog SDK, managing consent state, masking PII, and starting/stopping recordings. +- **Consent_Store**: The `localStorage` key `"sessionReplayConsent"` whose value is `"granted"` when the user has opted in, absent or any other value otherwise. +- **Replay_Opt_In_Toggle**: The settings-dropdown button that lets the user enable or disable session replay. Mirrors the existing expert-mode and theme toggles. +- **Consent_Dialog**: A modal overlay (reusing the existing disclaimer overlay pattern) that explains what is recorded and asks the user to confirm before the Replay_Opt_In_Toggle activates recording. +- **Transaction_Flow**: Any of the following async functions in `main.ts` whose `catch` block constitutes the error boundary: `openPosition`, `closePosition`, `repay` (inside `closePosition` two-step fallback), `withdraw`, `claim`, `increaseLeverage`, `decreaseLeverage`, `resupply`, `swapBlnd`, vault deposit, vault withdraw, vault rebalance. +- **Error_Boundary**: The `catch (e)` block wrapping a Transaction_Flow. When an error reaches this boundary, the Session_Replay_Module is notified. +- **PII_Mask_Config**: The PostHog `maskAllInputs` and custom `maskTextSelector` configuration that prevents wallet addresses and input field values from appearing in recordings. +- **PostHog_SDK**: The `posthog-js` npm package used as the session replay provider. + +--- + +## Requirements + +### Requirement 1: Opt-In Default State + +**User Story:** As a user, I want session replay to be off by default, so that my browsing activity is never recorded without my knowledge. + +#### Acceptance Criteria + +1. THE Session_Replay_Module SHALL initialise with recording disabled when `"sessionReplayConsent"` is absent from `localStorage`. +2. WHEN the application loads, THE Session_Replay_Module SHALL read `localStorage.getItem("sessionReplayConsent")` and SHALL NOT start a PostHog session recording unless the value equals `"granted"`. +3. THE Session_Replay_Module SHALL expose a `isReplayEnabled(): boolean` function that returns `true` if and only if `localStorage.getItem("sessionReplayConsent") === "granted"`. + +--- + +### Requirement 2: Explicit User Consent + +**User Story:** As a user, I want to be shown a clear consent dialog before session replay is activated, so that I understand what data is collected and can make an informed choice. + +#### Acceptance Criteria + +1. WHEN the user clicks the Replay_Opt_In_Toggle for the first time (consent not yet recorded), THE Consent_Dialog SHALL be displayed before any recording starts. +2. THE Consent_Dialog SHALL contain a plain-language description of what is recorded (browser interactions during transaction errors), what is masked (input field values, wallet addresses), and a link to the privacy policy. +3. WHEN the user confirms consent in the Consent_Dialog, THE Session_Replay_Module SHALL set `localStorage.setItem("sessionReplayConsent", "granted")` and SHALL update the Replay_Opt_In_Toggle badge to `"On"`. +4. WHEN the user dismisses the Consent_Dialog without confirming, THE Session_Replay_Module SHALL leave `"sessionReplayConsent"` unchanged and SHALL leave the Replay_Opt_In_Toggle badge as `"Off"`. +5. WHEN the user clicks the Replay_Opt_In_Toggle and consent is already `"granted"`, THE Session_Replay_Module SHALL toggle the feature off by setting `localStorage.setItem("sessionReplayConsent", "revoked")` and SHALL update the badge to `"Off"` without showing the Consent_Dialog again. +6. WHEN the application loads and `"sessionReplayConsent"` equals `"granted"`, THE Session_Replay_Module SHALL restore the Replay_Opt_In_Toggle badge to `"On"` without showing the Consent_Dialog. + +--- + +### Requirement 3: Error-Triggered Recording Only + +**User Story:** As a developer, I want session replay to capture only the moments when a transaction error occurs, so that recordings are focused and storage costs are minimised. + +#### Acceptance Criteria + +1. WHEN an Error_Boundary catches an error and `isReplayEnabled()` returns `true`, THE Session_Replay_Module SHALL call `startErrorReplay(context: ReplayContext)` to begin or resume a PostHog recording. +2. THE `ReplayContext` type SHALL include: `flowName: string` (the Transaction_Flow name), `errorMessage: string` (the caught error message, truncated to 500 characters), and `timestamp: number` (Unix ms). +3. WHEN `startErrorReplay` is called, THE Session_Replay_Module SHALL attach the `ReplayContext` fields as PostHog event properties on a `"tx_error_replay_started"` event. +4. WHILE a recording is active, THE Session_Replay_Module SHALL stop the recording automatically after 60 seconds by calling `posthog.stopSessionRecording()`. +5. IF `isReplayEnabled()` returns `false` when an Error_Boundary fires, THEN THE Session_Replay_Module SHALL take no recording action. +6. THE Session_Replay_Module SHALL NOT start a new recording if a recording is already active (debounce: one recording per error event). + +--- + +### Requirement 4: PII Masking — Input Fields + +**User Story:** As a user, I want all financial input values to be masked in recordings, so that my leverage amounts, deposit sizes, and other sensitive inputs are never captured. + +#### Acceptance Criteria + +1. THE Session_Replay_Module SHALL initialise PostHog with `maskAllInputs: true` so that all `` and `