diff --git a/.github/workflows/secrets-scan.yml b/.github/workflows/secrets-scan.yml new file mode 100644 index 00000000..0c68ff3e --- /dev/null +++ b/.github/workflows/secrets-scan.yml @@ -0,0 +1,26 @@ +name: Secrets Scanning + +on: + push: + branches: ['**'] + pull_request: + +permissions: + contents: read + pull-requests: write + +jobs: + gitleaks: + name: Scan for secrets + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} + with: + config-path: .gitleaks.toml diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..9cbac587 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,24 @@ +title = "Trivela Gitleaks Configuration" + +[extend] +useDefault = true + +[[rules]] +id = "stellar-secret-key" +description = "Stellar secret key (S... 56-char base32)" +regex = '''\bS[A-Z2-7]{55}\b''' +tags = ["stellar", "secret-key"] + +[[rules]] +id = "trivela-api-key" +description = "Trivela API key pattern" +regex = '''tvl_[a-zA-Z0-9]{32,}''' +tags = ["trivela", "api-key"] + +[allowlist] +description = "Safe paths — example files, test fixtures, docs" +paths = [ + '''.env\.example''', + '''test[s]?/fixtures/''', + '''docs/''', +] diff --git a/README.md b/README.md index 5a9d0682..a4811ccd 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Trivela +[![Secrets Scanning](https://github.com/FinesseStudioLab/Trivela/actions/workflows/secrets-scan.yml/badge.svg)](https://github.com/FinesseStudioLab/Trivela/actions/workflows/secrets-scan.yml) + > **New contributor?** Check out the [📖 FAQ](docs/FAQ.md) for common setup, contract, and > contribution questions before getting started. 🔤 **[Glossary](docs/GLOSSARY.md)** — definitions > for Soroban, XDR, TTL, Freighter, Horizon, and all Trivela-specific terms. diff --git a/backend/README.md b/backend/README.md index 1b744045..cec3eee5 100644 --- a/backend/README.md +++ b/backend/README.md @@ -678,3 +678,35 @@ Kubernetes, ECS) can use the resulting status: ```bash docker inspect --format '{{json .State.Health}}' ``` + +--- + +## API Versioning & Deprecation Policy + +All stable endpoints are served under `/api/v1/`. The legacy `/api/` prefix is an alias kept for backwards-compatibility; it will be removed after a 90-day deprecation window. + +### Deprecation notices + +When an endpoint is deprecated: + +1. It is added to `backend/src/deprecations.js` with `deprecatedAt` and `removedAt` dates. +2. Every response from that endpoint carries three headers (RFC 8594): + - `Deprecation: ` — when it was deprecated + - `Sunset: ` — when it will be removed (≥ 90 days after deprecation) + - `Link: ; rel="successor-version"` — the endpoint to migrate to +3. A `WARN` log line is emitted for each hit, letting operators see real traffic before removal. +4. The full registry is available at `GET /api/v1/deprecations` for programmatic checks. + +### 90-day notice minimum + +No endpoint may be removed until at least 90 days have passed since its `deprecatedAt` date. Operators watching the deprecation headers and the `/deprecations` endpoint will have ample time to migrate. + +### Content negotiation (v2 responses) + +Select endpoints support an early-access v2 response shape. Opt in with: + +``` +Accept: application/vnd.trivela.v2+json +``` + +Endpoints that honour this header document it in their section below. All others ignore it. diff --git a/backend/src/deprecations.js b/backend/src/deprecations.js new file mode 100644 index 00000000..b89de675 --- /dev/null +++ b/backend/src/deprecations.js @@ -0,0 +1,17 @@ +// @ts-check + +/** + * Deprecation registry — maps route patterns to lifecycle metadata. + * Add entries here before removing or replacing any endpoint. + * + * @type {Record} + */ +export const DEPRECATION_REGISTRY = { + // Example — uncomment when real deprecations land: + // 'GET /api/v1/campaigns/:id/stats': { + // deprecatedAt: '2024-09-01', + // removedAt: '2024-12-01', + // replacement: '/api/v1/campaigns/:id/analytics', + // message: 'Use the /analytics endpoint for richer campaign stats.', + // }, +}; diff --git a/backend/src/index.js b/backend/src/index.js index 357d0e4f..adbe1a06 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -46,6 +46,9 @@ import { MAX_IMAGE_SIZE_BYTES, } from './services/imageUpload.js'; import { buildCampaignStats } from './services/campaignStatsService.js'; +import { createCampaignExportRoute } from './routes/campaignExport.js'; +import { createDeprecationMiddleware } from './middleware/deprecationNotice.js'; +import { DEPRECATION_REGISTRY } from './deprecations.js'; import { generateAllowlist } from './lib/allowlist/merkle.js'; import { parseAllowlistCsv, validateGAddress, MAX_ALLOWLIST_ROWS } from './lib/allowlist/csv.js'; import { createEmbedRoute } from './routes/embed.js'; @@ -496,6 +499,7 @@ export async function createApp(options = {}) { app.use(compression({ threshold: 1024 })); app.use(cors(createCorsOptions(allowedOrigins))); app.use(securityHeaders); + app.use(createDeprecationMiddleware({ log })); app.use(traceparentMiddleware()); app.use(requestLogger); app.use(express.json({ limit: jsonBodyLimit })); @@ -1760,6 +1764,8 @@ export async function createApp(options = {}) { app.get(`${prefix}/campaigns/by-slug/:slug`, rateLimiter, getCampaignBySlug); app.get(`${prefix}/campaigns/:id`, rateLimiter, getCampaignById); app.get(`${prefix}/campaigns/:id/stats`, rateLimiter, getCampaignStats); + app.use(prefix, createCampaignExportRoute({ db: dal.db, campaignRepository, auditLogRepository, requireApiKey })); + app.get(`${prefix}/deprecations`, rateLimiter, (_req, res) => res.json({ deprecations: DEPRECATION_REGISTRY })); app.get(`${prefix}/audit-logs`, rateLimiter, ...guard, listAuditLogs); app.get(`${prefix}/admin/audit/verify`, rateLimiter, requireMasterKey, verifyAuditChain); app.get(`${prefix}/indexer/cursor`, rateLimiter, getIndexerCursorState); diff --git a/backend/src/middleware/deprecationNotice.js b/backend/src/middleware/deprecationNotice.js new file mode 100644 index 00000000..c933f9db --- /dev/null +++ b/backend/src/middleware/deprecationNotice.js @@ -0,0 +1,66 @@ +// @ts-check +import { DEPRECATION_REGISTRY } from '../deprecations.js'; + +/** + * Match a request path+method against the deprecation registry. + * Registry keys are like "GET /api/v1/campaigns/:id/stats"; path segments + * starting with ":" are treated as wildcards. + * + * @param {string} method e.g. "GET" + * @param {string} path e.g. "/api/v1/campaigns/42/stats" + * @returns {import('../deprecations.js').DeprecationEntry | null} + */ +function matchDeprecation(method, path) { + for (const [pattern, entry] of Object.entries(DEPRECATION_REGISTRY)) { + const [patternMethod, ...rest] = pattern.split(' '); + const patternPath = rest.join(' '); + + if (patternMethod.toUpperCase() !== method.toUpperCase()) continue; + + const patternParts = patternPath.split('/'); + const pathParts = path.split('/'); + + if (patternParts.length !== pathParts.length) continue; + + const matched = patternParts.every( + (seg, i) => seg.startsWith(':') || seg === pathParts[i], + ); + + if (matched) return entry; + } + return null; +} + +/** + * Express middleware that injects RFC 8594 deprecation headers for + * any route registered in the deprecation registry, and WARN-logs usage + * so operators know which deprecated endpoints are still being hit. + * + * @param {{ log?: { warn?: Function } }} [options] + * @returns {import('express').RequestHandler} + */ +export function createDeprecationMiddleware({ log = console } = {}) { + return function deprecationNotice(req, res, next) { + const entry = matchDeprecation(req.method, req.path); + + if (entry) { + const deprecationDate = new Date(entry.deprecatedAt).toUTCString(); + const sunsetDate = new Date(entry.removedAt).toUTCString(); + + res.setHeader('Deprecation', deprecationDate); + res.setHeader('Sunset', sunsetDate); + res.setHeader( + 'Link', + `<${entry.replacement}>; rel="successor-version"`, + ); + + log.warn?.( + `deprecated_endpoint_hit method=${req.method} path=${req.path} ` + + `deprecated_at=${entry.deprecatedAt} removed_at=${entry.removedAt} ` + + `replacement=${entry.replacement}`, + ); + } + + next(); + }; +} diff --git a/backend/src/routes/campaignExport.js b/backend/src/routes/campaignExport.js new file mode 100644 index 00000000..6846eaa4 --- /dev/null +++ b/backend/src/routes/campaignExport.js @@ -0,0 +1,172 @@ +// @ts-check +import { Router } from 'express'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +const EXPORT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; +const EXPORT_RATE_LIMIT_MAX = 5; + +/** @type {Map} */ +const _exportBuckets = new Map(); + +function checkExportRateLimit(campaignId, actorKey) { + const key = `${campaignId}:${actorKey}`; + const now = Date.now(); + const bucket = _exportBuckets.get(key); + + if (!bucket || bucket.resetAt <= now) { + _exportBuckets.set(key, { count: 1, resetAt: now + EXPORT_RATE_LIMIT_WINDOW_MS }); + return { allowed: true, remaining: EXPORT_RATE_LIMIT_MAX - 1, resetAt: now + EXPORT_RATE_LIMIT_WINDOW_MS }; + } + if (bucket.count >= EXPORT_RATE_LIMIT_MAX) { + return { allowed: false, remaining: 0, resetAt: bucket.resetAt }; + } + bucket.count += 1; + return { allowed: true, remaining: EXPORT_RATE_LIMIT_MAX - bucket.count, resetAt: bucket.resetAt }; +} + +/** + * RFC 4180-compliant CSV serializer. + * @param {string[]} columns + * @param {Record[]} rows + */ +function buildCsv(columns, rows) { + const escape = (v) => { + const s = v === null || v === undefined ? '' : String(v); + return s.includes(',') || s.includes('"') || s.includes('\n') || s.includes('\r') + ? '"' + s.replace(/"/g, '""') + '"' + : s; + }; + const lines = [columns.join(',')]; + for (const row of rows) lines.push(columns.map((c) => escape(row[c])).join(',')); + return lines.join('\n') + '\n'; +} + +/** + * @param {{ + * db: import('better-sqlite3').Database, + * campaignRepository: import('../dal/campaignRepository.js').CampaignRepository, + * auditLogRepository: import('../dal/auditLogRepository.js').AuditLogRepository, + * requireApiKey: import('express').RequestHandler, + * }} options + */ +export function createCampaignExportRoute({ db, campaignRepository, auditLogRepository, requireApiKey }) { + const router = Router(); + + router.get('/campaigns/:id/export', requireApiKey, async (req, res) => { + const { id } = req.params; + const format = String(req.query.format ?? 'csv').toLowerCase(); + const fromDate = typeof req.query.from === 'string' ? req.query.from : null; + const toDate = typeof req.query.to === 'string' ? req.query.to : null; + + if (format !== 'csv' && format !== 'json') { + return res.status(400).json({ error: 'Invalid format. Use ?format=csv or ?format=json', code: 'INVALID_FORMAT' }); + } + + const campaign = campaignRepository.getById(id); + if (!campaign) { + return res.status(404).json({ error: 'Campaign not found', code: 'NOT_FOUND' }); + } + + const actorKey = String(req.headers['x-api-key'] ?? req.query.api_key ?? req.ip ?? 'anon'); + const { allowed, remaining, resetAt } = checkExportRateLimit(id, actorKey); + res.setHeader('X-RateLimit-Limit', String(EXPORT_RATE_LIMIT_MAX)); + res.setHeader('X-RateLimit-Remaining', String(remaining)); + + if (!allowed) { + res.setHeader('Retry-After', String(Math.ceil((resetAt - Date.now()) / 1000))); + return res.status(429).json({ + error: 'Export rate limit exceeded. Max 5 exports per campaign per hour.', + code: 'EXPORT_RATE_LIMIT_EXCEEDED', + }); + } + + const hasCreditEvents = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='credit_events'").get(); + const hasClaimEvents = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='claim_events'").get(); + + let participants = []; + + if (hasCreditEvents) { + const dateFilters = []; + const vals = [String(id)]; + if (fromDate) { dateFilters.push("r.created_at >= ?"); vals.push(fromDate); } + if (toDate) { dateFilters.push("r.created_at <= ?"); vals.push(toDate); } + const dateWhere = dateFilters.length ? `AND ${dateFilters.join(' AND ')}` : ''; + + participants = db.prepare(` + WITH participants AS ( + SELECT DISTINCT user FROM credit_events + ), + credited AS ( + SELECT user, SUM(CAST(amount AS INTEGER)) AS total FROM credit_events GROUP BY user + ), + claimed AS ( + SELECT user, SUM(CAST(amount AS INTEGER)) AS total FROM claim_events GROUP BY user + ) + SELECT + p.user AS participantAddress, + r.created_at AS registeredAt, + COALESCE(cr.total, 0) AS pointsCredited, + COALESCE(cl.total, 0) AS pointsClaimed, + COALESCE(cr.total, 0) - COALESCE(cl.total, 0) AS netPoints, + ref.referrer_address AS referredBy + FROM participants p + LEFT JOIN referrals r + ON r.referee_address = p.user AND r.campaign_id = ? ${dateWhere} + LEFT JOIN credited cr ON cr.user = p.user + LEFT JOIN ${hasClaimEvents ? 'claimed' : '(SELECT NULL AS user, 0 AS total) dummy_cl'} cl ON cl.user = p.user + LEFT JOIN referrals ref + ON ref.referee_address = p.user AND ref.campaign_id = ? + `).all(...vals, String(id)); + } else { + // Fall back to referrals-only when event tables haven't been created yet + const dateFilters = []; + const vals = [String(id)]; + if (fromDate) { dateFilters.push("created_at >= ?"); vals.push(fromDate); } + if (toDate) { dateFilters.push("created_at <= ?"); vals.push(toDate); } + const dateWhere = dateFilters.length ? `AND ${dateFilters.join(' AND ')}` : ''; + + const rows = db.prepare(` + SELECT referee_address, referrer_address, created_at + FROM referrals + WHERE campaign_id = ? ${dateWhere} + ORDER BY created_at ASC + `).all(...vals); + + participants = rows.map((row) => ({ + participantAddress: row.referee_address, + registeredAt: row.created_at, + pointsCredited: 0, + pointsClaimed: 0, + netPoints: 0, + referredBy: row.referrer_address ?? null, + })); + } + + try { + auditLogRepository.create({ + actor: actorKey, + action: 'campaign.export', + entity: 'campaign', + entityId: id, + diff: { format, fromDate, toDate, rowCount: participants.length }, + }); + } catch (_err) { /* non-fatal */ } + + const filename = `campaign-${id}-export.${format}`; + + if (format === 'csv') { + const columns = ['participantAddress', 'registeredAt', 'pointsCredited', 'pointsClaimed', 'netPoints', 'referredBy']; + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + await pipeline(Readable.from([buildCsv(columns, participants)]), res); + } else { + const payload = JSON.stringify({ campaign: { id: campaign.id, name: campaign.name }, participants }, null, 2); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + await pipeline(Readable.from([payload]), res); + } + }); + + return router; +} diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 4db30367..d934d8fd 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -261,3 +261,59 @@ Required secrets for the CronJob: - `trivela-secrets.DATABASE_URL` — PostgreSQL connection string - `trivela-secrets.S3_BACKUP_BUCKET` — S3 bucket (if using S3) - `trivela-secrets.BACKUP_ENCRYPTION_PUBKEY` — age public key (optional) +# Trivela Runbook + +Operational procedures for the Trivela backend and infrastructure. + +--- + +## Secret Rotation Procedure + +If a private key, API key, or other secret is accidentally committed to the repository, follow these steps immediately. + +### 1. Assess the exposure + +- Determine what was committed: Stellar secret key, Trivela API key, environment variable, or third-party credential. +- Check if the commit reached GitHub (even briefly) — assume it did and treat it as compromised. + +### 2. Revoke / rotate the secret immediately + +| Secret type | Rotation action | +|---|---| +| Stellar secret key | Generate a new keypair. If the key held on-chain funds, sweep them to a new address first. | +| Trivela API key | Call `DELETE /api/v1/admin/api-keys/:id` to revoke the old key, then create a new one. | +| Third-party credential | Follow the provider's key rotation procedure. | + +Do **not** wait until the commit is removed before revoking — assume the secret is already exploited. + +### 3. Remove the secret from git history + +Use `git filter-repo` (preferred) or BFG Repo Cleaner to rewrite history: + +```bash +# Install: pip install git-filter-repo +git filter-repo --path-regex '.*' --replace-text <(echo 'COMPROMISED_VALUE==>REDACTED') +``` + +Then force-push all branches and tags. Coordinate with other contributors to re-clone. + +### 4. Request a GitHub secret scan review + +Open a GitHub support ticket to purge cached views of the exposed commit, and enable [GitHub's secret scanning alerts](https://docs.github.com/en/code-security/secret-scanning) if not already on. + +### 5. Post-incident + +- Add a custom rule to `.gitleaks.toml` for the leaked pattern if it is not already covered. +- Update `scripts/dev-setup.sh` if a `git-secrets` pattern needs to be added locally. +- Write a brief incident summary and share it with the team. + +--- + +## Gitleaks CI Failures + +If the `Secrets Scanning` CI workflow fails on a PR: + +1. **Do not merge** until the finding is resolved. +2. Read the workflow output to see which file/line triggered the rule (the secret value itself is not printed). +3. If it is a **false positive**, add an `[allowlist]` entry in `.gitleaks.toml` for that path or pattern, and explain why in the PR. +4. If it is a **real secret**, follow the rotation procedure above before amending the commit. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 402ae0d8..83865ec2 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -23,6 +23,7 @@ const About = lazy(() => import('./About')); const TransactionHistory = lazy(() => import('./TransactionHistory')); const EmbedCampaign = lazy(() => import('./pages/EmbedCampaign')); const PublicProfile = lazy(() => import('./pages/PublicProfile')); +const UserProfile = lazy(() => import('./pages/UserProfile')); import { applyTheme, getPreferredTheme, THEME_STORAGE_KEY } from './theme'; import { getRuntimeConfig, initializeRuntimeConfig, setRuntimeStellarNetwork } from './config'; import { @@ -350,6 +351,23 @@ export default function App() { /> } /> } /> + + } + /> ))} + {walletAddress && ( + + Profile + + )} {walletAddress && ( + {value} + {label} + + ); +} + +function Skeleton({ width = '100%', height = '1.2em' }) { + return ( +
+ +
+
+
+

My Profile

+
+ navigator.clipboard.writeText(walletAddress)} + > + {truncateAddress(walletAddress)} + + + (click to copy) + +
+
+ +
+ + {error && ( +

+ {error} +

+ )} + +
+ {loading ? ( + Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ )) + ) : ( + <> + + + + + + )} +
+ +
+

Recent Activity

+ {loading ? ( + Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ )) + ) : !profile?.recentActivity?.length ? ( +

No activity yet.

+ ) : ( +
    + {profile.recentActivity.map((event, i) => ( +
  • + {event.description ?? event.action} + +
  • + ))} +
+ )} +
+ +
+

Campaigns

+ {loading ? ( + + ) : !profile?.campaigns?.length ? ( +

No campaigns yet.

+ ) : ( +
+ )} +
+ + {profile?.joinedDate && ( +

+ Member since {new Date(profile.joinedDate).toLocaleDateString()} +

+ )} +
+ + ); +} diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh new file mode 100644 index 00000000..dd70e7f4 --- /dev/null +++ b/scripts/dev-setup.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Trivela local dev setup script. +# Run once after cloning to configure git hooks and tooling. + +set -euo pipefail + +echo "==> Setting up Trivela dev environment" + +# ── Node / npm ──────────────────────────────────────────────────────────────── +if ! command -v node &>/dev/null; then + echo "ERROR: Node.js is required. Install it from https://nodejs.org" >&2 + exit 1 +fi + +echo "==> Installing npm dependencies" +npm install + +# ── git-secrets pre-commit hook ─────────────────────────────────────────────── +# Scans staged files for secret patterns before each commit, mirroring the +# gitleaks CI check locally. Requires git-secrets to be installed. +# Install: https://github.com/awslabs/git-secrets#installing-git-secrets +if command -v git-secrets &>/dev/null; then + echo "==> Configuring git-secrets" + git secrets --install --force + git secrets --register-aws + # Stellar secret key pattern: S[A-Z2-7]{55} + git secrets --add 'S[A-Z2-7]{55}' + # Trivela API key pattern + git secrets --add 'tvl_[a-zA-Z0-9]{32,}' + # Allow .env.example and fixture paths + git secrets --add-provider -- echo '.env.example test/fixtures docs/' + echo " git-secrets configured. Staged secrets will be blocked pre-commit." +else + echo " WARN: git-secrets not found. Install it to enable local secret scanning." + echo " https://github.com/awslabs/git-secrets#installing-git-secrets" +fi + +echo "" +echo "Dev setup complete. Start the backend with: npm run dev"