-
Notifications
You must be signed in to change notification settings - Fork 173
feat(devops): add Husky pre-commit hooks for linting and type checkin… #676
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'; | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -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({ | ||||||||||||||
|
|
@@ -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) | ||||||||||||||
|
|
@@ -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); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // 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)); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -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); | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -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 }); | ||||||||||||||
|
|
@@ -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; | ||||||||||||||
|
|
@@ -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, | ||||||||||||||
| }; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -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; | ||||||||||||||
| }); | ||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Proposed minimal fix- ? 'Soroban RPC reachable'
+ ? 'Soroban RPC configured'📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| }, | ||||||||||||||
| contract: { | ||||||||||||||
| status: hasContractId ? 'up' : 'down', | ||||||||||||||
|
|
@@ -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, | ||||||||||||||
|
|
@@ -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({ | ||||||||||||||
|
|
@@ -532,12 +551,9 @@ app.post( | |||||||||||||
| sendValidationError(parsedId.issues); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| invalidateCampaignCache(); | ||||||||||||||
| res.status(result.existing ? 200 : 201).json({ | ||||||||||||||
| data: { | ||||||||||||||
|
|
||||||||||||||
| }, | ||||||||||||||
| data: {}, | ||||||||||||||
| }); | ||||||||||||||
| }, | ||||||||||||||
| ); | ||||||||||||||
|
|
@@ -675,7 +691,7 @@ app.get('/api/stats', cacheMiddleware(30), (_req: Request, res: Response) => { | |||||||||||||
| failedCampaigns: stats.campaignCountByStatus.failed, | ||||||||||||||
| totalPledgeVolume: stats.totalPledgedAmount, | ||||||||||||||
| uniqueContributors: stats.totalContributors, | ||||||||||||||
| } | ||||||||||||||
| }, | ||||||||||||||
| }); | ||||||||||||||
| }); | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -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 { | ||||||||||||||
|
|
@@ -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 { | ||||||||||||||
|
|
@@ -883,3 +908,5 @@ function startServer() { | |||||||||||||
| if (require.main === module) { | ||||||||||||||
| startServer(); | ||||||||||||||
| } | ||||||||||||||
| // test | ||||||||||||||
| // test | ||||||||||||||
There was a problem hiding this comment.
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.tsaccepts any Bearer token whenAPI_KEYSis empty. Since this file mounts that middleware in production, a deployment missingAPI_KEYShas no effective authentication. Require the variable during production startup or reject requests when it is absent.🤖 Prompt for AI Agents