Skip to content
Open
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
19 changes: 19 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,25 @@ Thank you for your interest in contributing to **Stellar Goal Vault**!
- TypeScript: ESLint + Prettier (pre-commit via Husky + lint-staged)
- Rust: `cargo fmt`

### Pre-commit hooks

This project uses Husky + lint-staged to run automated checks on staged files before each commit:

- **TypeScript/TSX files**: ESLint, Prettier formatting, and TypeScript type checking
- **Rust files**: `cargo clippy` linting

The hooks are designed to run in under 20 seconds for typical changes.

#### Bypassing pre-commit hooks

If you need to skip the pre-commit hooks (e.g., for a quick fix or when hooks are failing due to environment issues), use the `--no-verify` flag:

```bash
git commit --no-verify -m "your commit message"
```

Use this sparingly and ensure your code still meets the project's quality standards.

## Questions?

Check the [FAQ.md](./FAQ.md) before opening an issue. If your question isn't covered there, feel free to open a GitHub Discussion.
167 changes: 97 additions & 70 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
import compression from "compression";
import cors from "cors";
import "dotenv/config";
import express, { Request, Response } from "express";


import { validateEnv } from "./validateEnv";
import { z } from "zod";
import path from "path";
import { config, walletIntegrationReady } from "./config";
import { apiKeyAuthMiddleware } from "./middleware/apiKeyAuth";
import { cacheMiddleware } from "./middleware/cacheMiddleware";
import { requestIdMiddleware } from "./middleware/requestId";
import { validateBody } from "./middleware/validateBody";
import type { RequestWithId } from "./middleware/types";
import { initRedisCache } from "./services/cache";
import compression from 'compression';
import cors from 'cors';
import 'dotenv/config';
import express, { Request, Response } from 'express';

import { validateEnv } from './validateEnv';
import { z } from 'zod';
import path from 'path';
import { config, walletIntegrationReady } from './config';
import { apiKeyAuthMiddleware } from './middleware/apiKeyAuth';
import { cacheMiddleware } from './middleware/cacheMiddleware';
import { requestIdMiddleware } from './middleware/requestId';
import { validateBody } from './middleware/validateBody';
import type { RequestWithId } from './middleware/types';
import { initRedisCache } from './services/cache';

import swaggerUi from 'swagger-ui-express';

Expand Down Expand Up @@ -74,17 +73,23 @@ type CampaignListItem = CampaignRecord & { progress: CampaignProgress };
const CAMPAIGN_STATUSES: CampaignStatus[] = ['open', 'funded', 'claimed', 'failed'];
const CONTRACT_AMOUNT_DECIMALS = Number(process.env.CONTRACT_AMOUNT_DECIMALS ?? 2);
const RATE_LIMIT_WINDOW_MS = Number(process.env.RATE_LIMIT_WINDOW_MS ?? 60000);
const RATE_LIMIT_MAX_REQUESTS = Number(process.env.RATE_LIMIT_READ_LIMIT ?? process.env.RATE_LIMIT_MAX_REQUESTS ?? 120);
const WRITE_RATE_LIMIT_MAX_REQUESTS = Number(process.env.RATE_LIMIT_WRITE_LIMIT ?? process.env.WRITE_RATE_LIMIT_MAX_REQUESTS ?? 20);
const RATE_LIMIT_MAX_REQUESTS = Number(
process.env.RATE_LIMIT_READ_LIMIT ?? process.env.RATE_LIMIT_MAX_REQUESTS ?? 120,
);
const WRITE_RATE_LIMIT_MAX_REQUESTS = Number(
process.env.RATE_LIMIT_WRITE_LIMIT ?? process.env.WRITE_RATE_LIMIT_MAX_REQUESTS ?? 20,
);
const CAMPAIGN_DETAIL_PLEDGE_PREVIEW_LIMIT = 5;

app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'none'"],
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'none'"],
},
},
},
}));
}),
);

app.use(
cors({
Expand All @@ -102,13 +107,19 @@ app.use(
}
},
credentials: true,
exposedHeaders: ['X-Total-Count', 'X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-RateLimit-Reset', 'Retry-After'],
exposedHeaders: [
'X-Total-Count',
'X-RateLimit-Limit',
'X-RateLimit-Remaining',
'X-RateLimit-Reset',
'Retry-After',
],
}),
);

app.use(compression({ threshold: 1024 }));

const bodySizeLimit = process.env.MAX_BODY_SIZE || "16kb";
const bodySizeLimit = process.env.MAX_BODY_SIZE || '16kb';
app.use(express.json({ limit: bodySizeLimit }));

// OpenAPI documentation endpoints (public, not rate-limited or cached)
Expand All @@ -120,12 +131,12 @@ app.get('/api/docs', (_req: Request, res: Response) => {
app.use('/api/docs/ui', swaggerUi.serve, swaggerUi.setup(openApiDocument, { explorer: true }));

// Add API key authentication middleware (production only)
if (process.env.NODE_ENV === "production") {
if (process.env.NODE_ENV === 'production') {
app.use(apiKeyAuthMiddleware);
}
Comment on lines +134 to 136

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Fail closed when production API keys are missing.

backend/src/middleware/apiKeyAuth.ts accepts any Bearer token when API_KEYS is empty. Since this file mounts that middleware in production, a deployment missing API_KEYS has no effective authentication. Require the variable during production startup or reject requests when it is absent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/index.ts` around lines 134 - 136, Update the production setup in
the app initialization around apiKeyAuthMiddleware so authentication fails
closed when API_KEYS is missing. Require API_KEYS during production startup or
ensure the middleware rejects every request without configured keys, while
preserving normal authenticated behavior when keys are present.


// Add cache middleware for GET requests (production only, 5 minute TTL)
if (process.env.NODE_ENV === "production") {
if (process.env.NODE_ENV === 'production') {
app.use(cacheMiddleware(300));
}

Expand All @@ -144,10 +155,11 @@ export function applyRateLimit(limitOverride?: number) {
return next();
}

const isWrite = ["POST", "PUT", "PATCH", "DELETE"].includes(req.method);
const maxRequests = limitOverride ?? (isWrite ? WRITE_RATE_LIMIT_MAX_REQUESTS : RATE_LIMIT_MAX_REQUESTS);
const isWrite = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method);
const maxRequests =
limitOverride ?? (isWrite ? WRITE_RATE_LIMIT_MAX_REQUESTS : RATE_LIMIT_MAX_REQUESTS);

const key = `${req.ip}:${isWrite ? "write" : "read"}`;
const key = `${req.ip}:${isWrite ? 'write' : 'read'}`;
const now = Date.now();
const current = rateLimitBuckets.get(key);

Expand All @@ -159,14 +171,14 @@ export function applyRateLimit(limitOverride?: number) {
resetAt = current.resetAt;
}

res.setHeader("X-RateLimit-Limit", String(maxRequests));
res.setHeader("X-RateLimit-Remaining", String(Math.max(0, maxRequests - count)));
res.setHeader("X-RateLimit-Reset", String(Math.ceil(resetAt / 1000)));
res.setHeader('X-RateLimit-Limit', String(maxRequests));
res.setHeader('X-RateLimit-Remaining', String(Math.max(0, maxRequests - count)));
res.setHeader('X-RateLimit-Reset', String(Math.ceil(resetAt / 1000)));

if (current && now < current.resetAt && current.count >= maxRequests) {
const retryAfterSec = Math.max(1, Math.ceil((current.resetAt - now) / 1000));
res.setHeader("Retry-After", String(retryAfterSec));
throw new AppError("Rate limit exceeded. Please retry shortly.", 429, "RATE_LIMITED");
res.setHeader('Retry-After', String(retryAfterSec));
throw new AppError('Rate limit exceeded. Please retry shortly.', 429, 'RATE_LIMITED');
}

rateLimitBuckets.set(key, { count, resetAt });
Expand Down Expand Up @@ -220,9 +232,7 @@ export function normalizeAssetFilter(assetRaw: unknown): string | undefined {
return config.allowedAssets.includes(asset) ? asset : undefined;
}

export function normalizeStatusFilter(
statusRaw: unknown,
): CampaignStatus | undefined {
export function normalizeStatusFilter(statusRaw: unknown): CampaignStatus | undefined {
const status = normalizeQueryValue(statusRaw)?.toLowerCase();
if (!status) {
return undefined;
Expand All @@ -249,23 +259,37 @@ export function parseCampaignListFilters(query: {
sort?: CampaignSortField;
order?: SortOrder;
} {
const VALID_SORT_FIELDS: CampaignSortField[] = ['createdAt', 'deadline', 'pledgedAmount', 'targetAmount'];
const VALID_SORT_FIELDS: CampaignSortField[] = [
'createdAt',
'deadline',
'pledgedAmount',
'targetAmount',
];
const VALID_ORDERS: SortOrder[] = ['asc', 'desc'];
const rawSort = normalizeQueryValue(query.sort);
const rawOrder = normalizeQueryValue(query.order);

if (rawSort && !VALID_SORT_FIELDS.includes(rawSort as CampaignSortField)) {
throw new AppError(`Invalid sort field: ${rawSort}. Supported fields: ${VALID_SORT_FIELDS.join(', ')}`, 400, 'INVALID_SORT_FIELD');
throw new AppError(
`Invalid sort field: ${rawSort}. Supported fields: ${VALID_SORT_FIELDS.join(', ')}`,
400,
'INVALID_SORT_FIELD',
);
}

return {
asset: normalizeAssetFilter(query.asset),
status: normalizeStatusFilter(query.status),
searchQuery:
normalizeQueryValue(query.search) || normalizeQueryValue(query.q),
includeDeleted: query.includeDeleted === "true",
sort: rawSort && VALID_SORT_FIELDS.includes(rawSort as CampaignSortField) ? (rawSort as CampaignSortField) : undefined,
order: rawOrder && VALID_ORDERS.includes(rawOrder as SortOrder) ? (rawOrder as SortOrder) : undefined,
searchQuery: normalizeQueryValue(query.search) || normalizeQueryValue(query.q),
includeDeleted: query.includeDeleted === 'true',
sort:
rawSort && VALID_SORT_FIELDS.includes(rawSort as CampaignSortField)
? (rawSort as CampaignSortField)
: undefined,
order:
rawOrder && VALID_ORDERS.includes(rawOrder as SortOrder)
? (rawOrder as SortOrder)
: undefined,
};
}

Expand All @@ -277,10 +301,8 @@ export function filterCampaignList(
},
): CampaignListItem[] {
return campaigns.filter((campaign) => {
const matchesAsset =
!filters.asset || campaign.assetCode.toUpperCase() === filters.asset;
const matchesStatus =
!filters.status || campaign.progress.status === filters.status;
const matchesAsset = !filters.asset || campaign.assetCode.toUpperCase() === filters.asset;
const matchesStatus = !filters.status || campaign.progress.status === filters.status;

return matchesAsset && matchesStatus;
});
Expand Down Expand Up @@ -336,7 +358,9 @@ app.get('/api/health/deep', applyRateLimit(1000), async (_req: Request, res: Res
},
soroban: {
status: sorobanHealthy ? 'up' : 'down',
details: config.sorobanRpcUrl ? 'Soroban RPC reachable' : 'Soroban RPC URL not configured',
details: config.sorobanRpcUrl
? 'Soroban RPC reachable'
: 'Soroban RPC URL not configured',
Comment on lines +361 to +363

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Do not report a configured RPC as reachable.

This condition only checks whether config.sorobanRpcUrl exists; it performs no connectivity check. A configured but unavailable RPC will be reported as reachable, misleading health checks and monitoring.

Proposed minimal fix
-            ? 'Soroban RPC reachable'
+            ? 'Soroban RPC configured'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
details: config.sorobanRpcUrl
? 'Soroban RPC reachable'
: 'Soroban RPC URL not configured',
details: config.sorobanRpcUrl
? 'Soroban RPC configured'
: 'Soroban RPC URL not configured',
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/index.ts` around lines 361 - 363, Update the health-check details
in the surrounding status-building logic so a configured config.sorobanRpcUrl is
not described as “Soroban RPC reachable” without an actual connectivity result.
Use the existing RPC reachability check or report only configuration status,
preserving the unconfigured message when no URL is provided.

},
contract: {
status: hasContractId ? 'up' : 'down',
Expand Down Expand Up @@ -405,9 +429,7 @@ app.get('/api/campaigns', (req: Request, res: Response) => {
const page = params.page ?? 1;
const limit = params.limit ?? totalCount;
const totalPages =
params.limit === undefined || limit <= 0
? 1
: Math.max(1, Math.ceil(totalCount / limit));
params.limit === undefined || limit <= 0 ? 1 : Math.max(1, Math.ceil(totalCount / limit));

const responseBody = JSON.stringify({
data,
Expand Down Expand Up @@ -465,10 +487,7 @@ app.get('/api/campaigns/:id/pledges', (req: Request, res: Response) => {
page: paginationResult.page,
limit: paginationResult.limit,
});
const totalPages = Math.max(
1,
Math.ceil(totalCount / paginationResult.limit),
);
const totalPages = Math.max(1, Math.ceil(totalCount / paginationResult.limit));

res.setHeader('X-Total-Count', String(totalCount));
res.json({
Expand Down Expand Up @@ -532,12 +551,9 @@ app.post(
sendValidationError(parsedId.issues);
}


invalidateCampaignCache();
res.status(result.existing ? 200 : 201).json({
data: {

},
data: {},
});
},
);
Expand Down Expand Up @@ -675,7 +691,7 @@ app.get('/api/stats', cacheMiddleware(30), (_req: Request, res: Response) => {
failedCampaigns: stats.campaignCountByStatus.failed,
totalPledgeVolume: stats.totalPledgedAmount,
uniqueContributors: stats.totalContributors,
}
},
});
});

Expand Down Expand Up @@ -709,7 +725,12 @@ app.get('/api/leaderboard', (req: Request, res: Response) => {
});

function isErrorWithMessage(error: unknown): error is { message: string; [key: string]: unknown } {
return typeof error === 'object' && error !== null && 'message' in error && typeof (error as { message: unknown }).message === 'string';
return (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as { message: unknown }).message === 'string'
);
}

function isErrorWithType(error: unknown, type: string): boolean {
Expand Down Expand Up @@ -786,12 +807,16 @@ function printStartupBanner(): void {
const dbPath = process.env.DB_PATH || path.join(__dirname, '..', '..', 'data', 'campaigns.db');
const nodeEnv = process.env.NODE_ENV || 'development';

logInfo('startup_banner', {
message: 'Stellar Goal Vault Backend - Starting Up',
port: config.port,
environment: nodeEnv,
databasePath: dbPath,
}, config.logLevel);
logInfo(
'startup_banner',
{
message: 'Stellar Goal Vault Backend - Starting Up',
port: config.port,
environment: nodeEnv,
databasePath: dbPath,
},
config.logLevel,
);
}

export function configureHttpServer(server: Server): Server {
Expand Down Expand Up @@ -883,3 +908,5 @@ function startServer() {
if (require.main === module) {
startServer();
}
// test
// test
Loading