Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/secrets-scan.yml
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -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/''',
]
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
32 changes: 32 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -678,3 +678,35 @@ Kubernetes, ECS) can use the resulting status:
```bash
docker inspect --format '{{json .State.Health}}' <container-id>
```

---

## 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: <RFC-7231 date>` — when it was deprecated
- `Sunset: <RFC-7231 date>` — when it will be removed (≥ 90 days after deprecation)
- `Link: <replacement_url>; 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.
17 changes: 17 additions & 0 deletions backend/src/deprecations.js
Original file line number Diff line number Diff line change
@@ -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<string, { deprecatedAt: string, removedAt: string, replacement: string, message: string }>}
*/
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.',
// },
};
6 changes: 6 additions & 0 deletions backend/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 }));
Expand Down Expand Up @@ -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);
Expand Down
66 changes: 66 additions & 0 deletions backend/src/middleware/deprecationNotice.js
Original file line number Diff line number Diff line change
@@ -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();
};
}
172 changes: 172 additions & 0 deletions backend/src/routes/campaignExport.js
Original file line number Diff line number Diff line change
@@ -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<string, { count: number, resetAt: number }>} */
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<string, unknown>[]} 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');

Check warning

Code scanning / CodeQL

Sensitive data read from GET request Medium

Route handler
for GET requests uses query parameter as sensitive data.
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;
}
Loading
Loading