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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ jobs:
env:
DATABASE_URL: postgresql://postgres:password@127.0.0.1:5432/flowfi_test

- name: Lint
run: npm run lint
working-directory: backend

- name: Build
run: npm run build
working-directory: backend
Expand Down
52 changes: 52 additions & 0 deletions backend/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// ESLint flat config for the FlowFi backend (Node.js/Express + TypeScript).
// Mirrors the frontend setup (frontend/eslint.config.mjs), adapted for a
// non-React, Node-first codebase via typescript-eslint's recommended ruleset.
import { defineConfig, globalIgnores } from "eslint/config";
import tseslint from "typescript-eslint";

const eslintConfig = defineConfig([
globalIgnores([
"node_modules/**",
"dist/**",
"coverage/**",
"src/generated/**",
"examples/**",
"src/**/*.example.ts",
]),
...tseslint.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
rules: {
// Forbid raw console.* calls in favour of the winston-based logger
// (src/logger.ts).
"no-console": "error",
// Allow the conventional `_`-prefix for intentionally-unused
// parameters, destructured variables, and caught errors.
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
},
},
{
// Seed scripts legitimately print to stdout — console is fine there.
files: ["prisma/seed.ts"],
rules: {
"no-console": "off",
},
},
{
// Test files use `any` liberally for mocks/test doubles; keep them linted
// but surface `any` as a warning instead of blocking.
files: ["tests/**/*.ts"],
rules: {
"@typescript-eslint/no-explicit-any": "warn",
},
},
]);

export default eslintConfig;
4 changes: 4 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"test:integration": "vitest run tests/integration",
"test:integration:docker": "docker compose up -d postgres && vitest run tests/integration/stream-lifecycle.test.ts; docker compose stop postgres",
"dev": "nodemon",
"lint": "eslint .",
"build": "tsc",
"start": "node dist/index.js",
"prisma:generate": "prisma generate",
Expand Down Expand Up @@ -43,6 +44,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^9.39.3",
"@types/cors": "^2.8.19",
"@types/eventsource": "^1.1.15",
"@types/express": "^5.0.6",
Expand All @@ -51,13 +53,15 @@
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.6",
"@vitest/coverage-v8": "^3.2.7",
"eslint": "^9.39.3",
"eventsource": "^2.0.2",
"nodemon": "^3.1.11",
"prisma": "^7.4.1",
"supertest": "^7.1.0",
"ts-node": "^10.9.2",
"tsx": "^4.19.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1",
"vitest": "^3.2.4"
}
}
12 changes: 6 additions & 6 deletions backend/src/controllers/stream.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,17 +564,17 @@ export const getUserStreamSummary = async (

const totalStreamsCreated = outgoingStreams.length;
const totalStreamedOut = sumStringI128(
outgoingStreams.map((stream: any) => stream.withdrawnAmount),
outgoingStreams.map((stream) => stream.withdrawnAmount),
);
const totalStreamedIn = sumStringI128(
incomingStreams.map((stream: any) => stream.withdrawnAmount),
incomingStreams.map((stream) => stream.withdrawnAmount),
);

const activeOutgoingCount = outgoingStreams.filter(
(stream: any) => stream.isActive,
(stream) => stream.isActive,
).length;
const activeIncomingCount = incomingStreams.filter(
(stream: any) => stream.isActive,
(stream) => stream.isActive,
).length;

const truncated =
Expand Down Expand Up @@ -685,9 +685,9 @@ export const topUpStreamHandler = async (req: Request, res: Response) => {
return res
.status(200)
.json({ streamId, txHash, depositedAmount: updatedStream!.depositedAmount });
} catch (error: any) {
} catch (error) {
logger.error(`[topUp] stream=${streamId} error:`, error);
return res.status(400).json({ error: 'Failed to top up stream on chain', message: error.message ?? 'Unknown error' });
return res.status(400).json({ error: 'Failed to top up stream on chain', message: error instanceof Error ? error.message : 'Unknown error' });
}
};

Expand Down
7 changes: 4 additions & 3 deletions backend/src/controllers/stream/cancel.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Response } from 'express';
import type { Request, Response } from 'express';
import { prisma } from '../../lib/prisma.js';
import logger from '../../logger.js';
import * as sorobanService from '../../services/sorobanService.js';
Expand Down Expand Up @@ -46,10 +46,11 @@ import { parseStreamId } from '../../lib/stream-id.js';
* 409:
* description: Stream already cancelled or completed
*/
export const cancelStreamHandler = async (req: AuthenticatedRequest, res: Response) => {
export const cancelStreamHandler = async (req: Request, res: Response) => {
try {
const authReq = req as AuthenticatedRequest;
const streamIdParam = req.params.streamId;
const callerAddress = req.user.publicKey;
const callerAddress = authReq.user.publicKey;

const streamId = Array.isArray(streamIdParam) ? streamIdParam[0] : streamIdParam;
if (!streamId) {
Expand Down
2 changes: 1 addition & 1 deletion backend/src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ export const getCurrentUser = async (
const { publicKey } = authReq.user;

// Try to get user from database
let user = await prisma.user.findUnique({
const user = await prisma.user.findUnique({
where: { publicKey },
include: {
sentStreams: {
Expand Down
22 changes: 11 additions & 11 deletions backend/src/controllers/webhook.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ export async function createWebhook(
secretKey, // Only returned once on creation
message: "Store the secret key securely - it will not be shown again",
});
} catch (error: any) {
} catch (error: unknown) {
logger.error("[Webhook Controller] Create error:", error);
res
.status(500)
.json({ error: error.message || "Failed to create webhook" });
res.status(500).json({
error: error instanceof Error ? error.message : "Failed to create webhook",
});
}
}

Expand All @@ -55,11 +55,11 @@ export async function listWebhooks(req: Request, res: Response): Promise<void> {

// Don't expose secret keys
const safeSubscriptions = subscriptions.map(
({ secretKey, ...rest }) => rest,
({ secretKey: _secretKey, ...rest }) => rest,
);

res.json({ subscriptions: safeSubscriptions });
} catch (error: any) {
} catch (error: unknown) {
logger.error("[Webhook Controller] List error:", error);
res.status(500).json({ error: "Failed to list webhooks" });
}
Expand All @@ -82,7 +82,7 @@ export async function deleteWebhook(
await webhookService.deleteWebhookSubscription(id, userAddress);

res.status(204).send();
} catch (error: any) {
} catch (error: unknown) {
logger.error("[Webhook Controller] Delete error:", error);
res.status(500).json({ error: "Failed to delete webhook" });
}
Expand All @@ -105,10 +105,10 @@ export async function testWebhook(req: Request, res: Response): Promise<void> {
message: "Test webhook sent",
result,
});
} catch (error: any) {
} catch (error: unknown) {
logger.error("[Webhook Controller] Test error:", error);
res
.status(500)
.json({ error: error.message || "Failed to send test webhook" });
res.status(500).json({
error: error instanceof Error ? error.message : "Failed to send test webhook",
});
}
}
9 changes: 5 additions & 4 deletions backend/src/lib/prisma-sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ export function getSandboxPrisma(): PrismaClient {
return globalForSandboxPrisma.sandboxPrisma;
}

const sandboxPrisma = new PrismaClient({
log: process.env.NODE_ENV === 'development'
const log: Array<'query' | 'info' | 'warn' | 'error'> =
process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error'],
} as any);
: ['error'];

const sandboxPrisma = new PrismaClient({ log });

if (process.env.NODE_ENV !== 'production') {
globalForSandboxPrisma.sandboxPrisma = sandboxPrisma;
Expand Down
4 changes: 2 additions & 2 deletions backend/src/lib/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const DEFAULT_MEMORY_CACHE_MAX_ITEMS = 10_000;
* memory usage stays bounded regardless of key churn or sweep interval.
*/
export class MemoryCache {
private cache = new Map<string, CacheItem<any>>();
private cache = new Map<string, CacheItem<unknown>>();
private hits = 0;
private misses = 0;
private readonly maxItems: number;
Expand Down Expand Up @@ -74,7 +74,7 @@ export class MemoryCache {
// the last candidate for eviction when the max-size cap is hit.
this.cache.delete(key);
this.cache.set(key, item);
return item.value;
return item.value as T;
}

set<T>(key: string, value: T, ttlSeconds: number): void {
Expand Down
2 changes: 1 addition & 1 deletion backend/src/middleware/admin-rate-limiter.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const adminRateLimiter = rateLimit({
}
return req.ip ?? 'unknown';
},
skip: (req: Request): boolean => {
skip: (_req: Request): boolean => {
// Skip rate limiting in test environment
return process.env.NODE_ENV === 'test';
},
Expand Down
7 changes: 6 additions & 1 deletion backend/src/middleware/error.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ export const errorHandler = (
}

// Default Error
const statusCode = (err instanceof Error && (err as any).status) || (err instanceof Error && (err as any).statusCode) || 500;
const statusError =
err instanceof Error
? (err as Error & { status?: number; statusCode?: number })
: null;
const statusCode =
statusError?.status || statusError?.statusCode || 500;
const message = err instanceof Error ? err.message : 'Internal Server Error';

return res.status(statusCode).json({
Expand Down
5 changes: 3 additions & 2 deletions backend/src/middleware/stream-rate-limiter.middleware.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { type Options } from 'express-rate-limit';
import { createRateLimiter } from './rate-limiter.middleware.js';
import { type Request, type Response, type NextFunction } from 'express';
import type { AuthenticatedRequest } from '../types/auth.types.js';
Expand Down Expand Up @@ -42,15 +43,15 @@ export function createStreamRateLimiter(
* Skip rate limiting for non-authenticated requests
* to ensure we only rate limit authenticated users
*/
skip: (req: Request, res: Response): boolean => {
skip: (req: Request, _res: Response): boolean => {
const authReq = req as AuthenticatedRequest;
if (!authReq.user?.publicKey) {
logger.warn('Stream creation rate limiter skipped: no authenticated user');
return true;
}
return false;
},
handler: (req: Request, res: Response, next: NextFunction, options: any): void => {
handler: (req: Request, res: Response, _next: NextFunction, options: Options): void => {
const authReq = req as AuthenticatedRequest;
logger.warn(
`Rate limit exceeded for wallet: ${authReq.user?.publicKey || 'unknown'}`,
Expand Down
10 changes: 5 additions & 5 deletions backend/src/routes/v1/admin.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,8 @@ router.get('/metrics', async (_req: Request, res: Response) => {
cache.set(ADMIN_METRICS_CACHE_KEY, payload, ADMIN_METRICS_CACHE_TTL_SECONDS);
res.set('X-Cache', 'MISS');
res.json(withCalculatedAt(payload));
} catch (err) {
logger.error('Error fetching admin metrics:', err);
} catch (_err) {
logger.error('Error fetching admin metrics:', _err);
res.status(500).json({ error: 'Internal server error' });
}
});
Expand Down Expand Up @@ -336,7 +336,7 @@ router.get('/indexer/status', async (req: Request, res: Response) => {
try {
const status = await getIndexerStatus();
res.json(status);
} catch (err) {
} catch (_err) {
res.status(500).json({ error: 'Failed to fetch indexer status' });
}
});
Expand Down Expand Up @@ -417,7 +417,7 @@ router.post('/indexer/reset', async (req: Request, res: Response) => {
}
await resetIndexer(ledger);
res.json({ ok: true, lastLedger: ledger });
} catch (err) {
} catch (_err) {
res.status(500).json({ error: 'Reset failed' });
}
});
Expand Down Expand Up @@ -496,7 +496,7 @@ router.post('/indexer/replay', async (req: Request, res: Response) => {
}
const requestId = await replayFromLedger(fromLedger);
res.status(202).json({ ok: true, replayingFrom: fromLedger, requestId });
} catch (err) {
} catch (_err) {
res.status(500).json({ error: 'Replay failed' });
}
});
Expand Down
4 changes: 2 additions & 2 deletions backend/src/routes/v1/stream.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,7 @@ router.post('/:streamId/resume', requireAuth, resumeStream);
* schema:
* $ref: '#/components/schemas/Error'
*/
router.post('/:streamId/withdraw', requireAuth, withdrawHandler as any);
router.post('/:streamId/withdraw', requireAuth, withdrawHandler);

/**
* @openapi
Expand Down Expand Up @@ -718,6 +718,6 @@ router.post('/:streamId/top-up', requireAuth, topUpStreamHandler);
* schema:
* $ref: '#/components/schemas/Error'
*/
router.post('/:streamId/cancel', requireAuth, cancelStreamHandler as any);
router.post('/:streamId/cancel', requireAuth, cancelStreamHandler);

export default router;
13 changes: 7 additions & 6 deletions backend/src/routes/v1/streams/withdraw.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Response } from 'express';
import type { Request, Response } from 'express';
import { prisma } from '../../../lib/prisma.js';
import logger from '../../../logger.js';
import { claimableAmountService } from '../../../services/claimable.service.js';
Expand Down Expand Up @@ -43,8 +43,9 @@ import { parseStreamId } from '../../../lib/stream-id.js';
* 500:
* description: Internal server error
*/
export const withdrawHandler = async (req: AuthenticatedRequest, res: Response) => {
export const withdrawHandler = async (req: Request, res: Response) => {
try {
const authReq = req as AuthenticatedRequest;
const streamIdParam = Array.isArray(req.params.streamId)
? req.params.streamId[0]
: req.params.streamId;
Expand Down Expand Up @@ -78,7 +79,7 @@ export const withdrawHandler = async (req: AuthenticatedRequest, res: Response)
}

// Verify the caller is the stream recipient
if (stream.recipient !== req.user.publicKey) {
if (stream.recipient !== authReq.user.publicKey) {
return res.status(403).json({
error: 'Forbidden',
message: 'Only the stream recipient can withdraw from the stream',
Expand All @@ -96,7 +97,7 @@ export const withdrawHandler = async (req: AuthenticatedRequest, res: Response)

try {
// Call Soroban service
const result = await sorobanWithdraw(parsedStreamId, req.user.publicKey);
const result = await sorobanWithdraw(parsedStreamId, authReq.user.publicKey);

const now = BigInt(Math.floor(Date.now() / 1000));
const withdrawAmount = BigInt(claimable.claimableAmount);
Expand Down Expand Up @@ -151,12 +152,12 @@ export const withdrawHandler = async (req: AuthenticatedRequest, res: Response)
transactionHash: result.txHash,
ledgerSequence: 0,
timestamp: now,
metadata: JSON.stringify({ withdrawnBy: req.user.publicKey }),
metadata: JSON.stringify({ withdrawnBy: authReq.user.publicKey }),
},
update: {},
});

logger.info(`Stream ${parsedStreamId} withdrawn by ${req.user.publicKey}`);
logger.info(`Stream ${parsedStreamId} withdrawn by ${authReq.user.publicKey}`);

return res.status(200).json({
success: true,
Expand Down
Loading
Loading