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
2 changes: 1 addition & 1 deletion backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,6 @@ import { Module } from "@nestjs/common";
},
}),
ObservabilityModule,
CustomThrottlerModule,
PrismaModule,
ScheduleModule.forRoot(),
HealthModule,
Expand All @@ -144,6 +143,7 @@ import { Module } from "@nestjs/common";
AdminAnalyticsModule,
ActivityFeedModule,
RealtimeModule,
CustomThrottlerModule,
],
providers: [
PrismaService,
Expand Down
6 changes: 3 additions & 3 deletions backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
import { Throttle } from "@nestjs/throttler";
import { AuthService } from "./auth.service";
import { NonceRequestDto, VerifyRequestDto } from "./dtos/auth.dto";
import { Auth, Public, CurrentUser } from "./guard/auth.guard";
import { Auth, CurrentUser, Public } from "./guard/auth.guard";
import { User } from "../users/user.entity";

@Controller("auth")
Expand All @@ -22,7 +22,7 @@ export class AuthController {
*/
@Public()
@Post("nonce")
@Throttle({ default: { limit: 5, ttl: 900 } }) // 5 requests per 15 minutes
@Throttle({ default: { limit: 5, ttl: 900_000 } }) // 5 requests per 15 minutes
@HttpCode(HttpStatus.OK)
async nonce(@Body() dto: NonceRequestDto) {
return this.authService.generateNonce(dto);
Expand All @@ -34,7 +34,7 @@ export class AuthController {
*/
@Public()
@Post("verify")
@Throttle({ default: { limit: 5, ttl: 900 } }) // 5 requests per 15 minutes
@Throttle({ default: { limit: 5, ttl: 900_000 } }) // 5 requests per 15 minutes
@HttpCode(HttpStatus.OK)
async verify(@Body() dto: VerifyRequestDto) {
return this.authService.verify(dto);
Expand Down
1 change: 1 addition & 0 deletions backend/src/config/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ import { registerAs } from "@nestjs/config";
export default registerAs("app", () => ({
port: parseInt(process.env.PORT || "3001", 10),
corsOrigin: process.env.CORS_ORIGIN || "http://localhost:3000",
trustProxy: process.env.TRUST_PROXY || 1,
version: "0.0.1",
}));
2 changes: 1 addition & 1 deletion backend/src/customers/customers.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export class CustomersController {
*/
@Post()
@Auth()
@Throttle({ default: { limit: 30, ttl: 3600 } })
@Throttle({ default: { limit: 30, ttl: 3_600_000 } })
async create(@CurrentUser() user: User, @Body() dto: CreateCustomerDto) {
return await this.prisma.runWithMerchantScope(user.merchantId, () =>
this.customersService.create(user.merchantId, dto),
Expand Down
52 changes: 27 additions & 25 deletions backend/src/health/health.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
import { Test, TestingModule } from "@nestjs/testing";
import { HealthController } from "./health.controller";
import { HealthService } from "./health.service";

describe('HealthController', () => {
describe("HealthController", () => {
let controller: HealthController;

beforeEach(async () => {
Expand All @@ -12,33 +12,35 @@ describe('HealthController', () => {
{
provide: HealthService,
useValue: {
checkReadiness: jest.fn(() => Promise.resolve({
ok: true,
version: '0.0.1',
network: 'testnet',
timestamp: new Date().toISOString(),
anchoring: {
enabled: false,
contractIdConfigured: false,
adminKeyConfigured: false,
message: 'Anchoring is disabled',
},
checks: {
postgres: { status: 'up', latencyMs: 10 },
horizon: { status: 'up', latencyMs: 20 },
soroban_rpc: { status: 'up', latencyMs: 30 },
},
})),
checkReadiness: jest.fn(() =>
Promise.resolve({
ok: true,
version: "0.0.1",
network: "testnet",
timestamp: new Date().toISOString(),
anchoring: {
enabled: false,
contractIdConfigured: false,
adminKeyConfigured: false,
message: "Anchoring is disabled",
},
checks: {
postgres: { status: "up", latencyMs: 10 },
horizon: { status: "up", latencyMs: 20 },
soroban_rpc: { status: "up", latencyMs: 30 },
},
}),
),
checkLiveness: jest.fn(() => ({
ok: true,
version: '0.0.1',
network: 'testnet',
version: "0.0.1",
network: "testnet",
timestamp: new Date().toISOString(),
anchoring: {
enabled: false,
contractIdConfigured: false,
adminKeyConfigured: false,
message: 'Anchoring is disabled',
message: "Anchoring is disabled",
},
})),
},
Expand All @@ -49,7 +51,7 @@ describe('HealthController', () => {
controller = module.get<HealthController>(HealthController);
});

it('should be defined', () => {
it("should be defined", () => {
expect(controller).toBeDefined();
});
});
2 changes: 2 additions & 0 deletions backend/src/health/health.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export class HealthController {
* Liveness probe — confirms the process is running.
* No dependency checks; safe to call frequently.
*/
@Public()
@Get()
@HttpCode(HttpStatus.OK)
checkLiveness() {
Expand All @@ -31,6 +32,7 @@ export class HealthController {
* Returns structured per-service status.
* HTTP 200 when all dependencies are healthy, HTTP 503 when any are down.
*/
@Public()
@Get("ready")
async checkReadiness(@Res() res: Response) {
const report: HealthReport = await this.healthService.checkReadiness();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export class InvoiceEngagementController {
constructor(private readonly engagementService: InvoiceEngagementService) {}

@Public()
@Throttle({ default: { limit: 30, ttl: 60 } }) // 30 events per minute per IP
@Throttle({ default: { limit: 30, ttl: 60_000 } }) // 30 events per minute per IP
@Post()
@HttpCode(HttpStatus.ACCEPTED)
async create(
Expand Down
9 changes: 5 additions & 4 deletions backend/src/invoices/invoices.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { SearchInvoicesDto } from "./dto/search-invoices.dto";
import { ImportSummaryDto } from "./dto/import-result.dto";
import { Invoice } from "./entities/invoice.entity";
import { InvoiceStatus } from "@prisma/client";
import { Auth, CurrentUser } from "../auth/guard/auth.guard";
import { Auth, CurrentUser, Public } from "../auth/guard/auth.guard";
import { User } from "../users/user.entity";
import { PrismaService } from "../prisma/prisma.service";
import {
Expand Down Expand Up @@ -114,8 +114,9 @@ export class InvoicesController {
* @param id - Invoice UUID
* @returns Public invoice data
*/
@Public()
@Get("public/:id")
@Throttle({ default: { limit: 60, ttl: 60000 } }) // 60 requests per minute
@Throttle({ default: { limit: 60, ttl: 60_000 } }) // 60 requests per minute
async findPublicInvoice(@Param("id") id: string) {
const invoice = await this.invoicesService.findPublicInvoice(id);
if (!invoice) {
Expand All @@ -132,7 +133,7 @@ export class InvoicesController {
*/
@Post()
@Auth()
@Throttle({ default: { limit: 20, ttl: 3600 } }) // 20 invoices per hour per user
@Throttle({ default: { limit: 20, ttl: 3_600_000 } }) // 20 invoices per hour per user
async create(
@CurrentUser() user: User,
@Body() dto: CreateInvoiceDto,
Expand All @@ -151,7 +152,7 @@ export class InvoicesController {
*/
@Post("import")
@Auth()
@Throttle({ default: { limit: 3, ttl: 3600 } }) // 3 imports per hour per user
@Throttle({ default: { limit: 3, ttl: 3_600_000 } }) // 3 imports per hour per user
@UseInterceptors(
FileInterceptor("file", {
storage: memoryStorage(),
Expand Down
7 changes: 7 additions & 0 deletions backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ async function bootstrap() {
// Get config service
const configService = app.get(ConfigService);

// Trust proxy so req.ip reflects the real client IP behind a load balancer.
// Defaults to 1 (single proxy hop, e.g. Render.com). Configure via TRUST_PROXY env var.
const httpAdapter = app.getHttpAdapter();
const expressInstance = httpAdapter.getInstance();
expressInstance.set("trust proxy", configService.get("app.trustProxy", 1));

// Enable CORS for frontend
// ─── Security Headers ─────────────────────────────────────────────
app.use(helmet());

Expand Down
14 changes: 11 additions & 3 deletions backend/src/prisma/merchant-scope.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const TENANT_SCOPED_MODELS = new Set([
"RecurringSchedule",
"User",
"WebhookDeadLetter",
"WebhookDelivery"
"WebhookDelivery",
]);

export function applyMerchantScope(
Expand Down Expand Up @@ -63,7 +63,11 @@ export function applyMerchantScope(
};
}

if (params.action === "createMany" && Array.isArray(args.data) && !isMerchantRoot) {
if (
params.action === "createMany" &&
Array.isArray(args.data) &&
!isMerchantRoot
) {
args.data = args.data.map((record: Record<string, unknown>) => ({
...record,
[tenantKey]: record[tenantKey] ?? merchantId,
Expand Down Expand Up @@ -118,7 +122,11 @@ function canAutoScopeWhere(action: string): boolean {
].includes(action);
}

function withMerchantFilter(where: unknown, merchantId: string, tenantKey: string) {
function withMerchantFilter(
where: unknown,
merchantId: string,
tenantKey: string,
) {
if (!where || typeof where !== "object") {
return { [tenantKey]: merchantId };
}
Expand Down
5 changes: 4 additions & 1 deletion backend/src/prisma/prisma.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Logger } from "@nestjs/common";
import { prismaExtensionCallback } from "./prisma.service";
import { MerchantContextService, UNSCOPED_MERCHANT_CONTEXT } from "./merchant-context.service";
import {
MerchantContextService,
UNSCOPED_MERCHANT_CONTEXT,
} from "./merchant-context.service";
import { StructuredLogger } from "../observability/structured-logger.service";

type Deps = Parameters<typeof prismaExtensionCallback>[0];
Expand Down
11 changes: 7 additions & 4 deletions backend/src/soroban/soroban.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@ describe("SorobanService", () => {
get: jest.fn().mockReturnValue({
sorobanRpcUrl: "https://soroban-testnet.stellar.org",
networkPassphrase: "Test SDF Network ; September 2015",
contractId: "CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
adminSecretKey: "SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
merchantPublicKey: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
contractId:
"CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
adminSecretKey:
"SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
merchantPublicKey:
"GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
}),
},
},
Expand Down Expand Up @@ -63,7 +66,7 @@ describe("SorobanService", () => {
assetIssuer: "",
amount: "100",
settlementRef: "ref-123",
})
}),
).rejects.toThrow("SorobanService not initialized");
});

Expand Down
10 changes: 7 additions & 3 deletions backend/src/soroban/soroban.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,15 @@ export class SorobanService implements OnModuleInit {
}

async pingRpc(): Promise<RpcCheckResult> {
const cfg = this.configService.get("stellar") as any;
const cfg = this.configService.get("stellar");
if (!cfg || !cfg.sorobanRpcUrl) {
return { reachable: false, latencyMs: 0, error: "Soroban RPC URL not configured" };
return {
reachable: false,
latencyMs: 0,
error: "Soroban RPC URL not configured",
};
}

const start = Date.now();
try {
// Import dynamically or rely on global fetch since we don't want to mess up stellar-sdk import
Expand Down
13 changes: 10 additions & 3 deletions backend/src/stellar/horizon-watcher.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,10 @@ describe("HorizonWatcherService cursor persistence", () => {
}

it("treats PaymentAlreadyRecorded as a benign retry: no anchoring failure recorded", async () => {
const payment = makePayment({ paging_token: "900", id: "already-recorded" });
const payment = makePayment({
paging_token: "900",
id: "already-recorded",
});
mockStellarService.getServer.mockReturnValue(makeServer([payment]));
mockSorobanService.recordPayment.mockRejectedValue(
new SorobanContractError(
Expand All @@ -339,7 +342,9 @@ describe("HorizonWatcherService cursor persistence", () => {
);

await service.pollPayments();
await waitFor(() => mockSorobanService.recordPayment.mock.calls.length > 0);
await waitFor(
() => mockSorobanService.recordPayment.mock.calls.length > 0,
);
await flushAnchoringCatch();

expect(mockInvoicesService.recordAnchoringFailure).not.toHaveBeenCalled();
Expand Down Expand Up @@ -368,7 +373,9 @@ describe("HorizonWatcherService cursor persistence", () => {
mockSorobanService.getSettlementRefOwner.mockResolvedValue("42");

await service.pollPayments();
await waitFor(() => mockSorobanService.getSettlementRefOwner.mock.calls.length > 0);
await waitFor(
() => mockSorobanService.getSettlementRefOwner.mock.calls.length > 0,
);
await flushAnchoringCatch();

expect(mockSorobanService.getSettlementRefOwner).toHaveBeenCalledWith(
Expand Down
11 changes: 4 additions & 7 deletions backend/src/stellar/horizon-watcher.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,16 +564,13 @@ export class HorizonWatcherService implements OnModuleInit, OnModuleDestroy {
?.split(",")
.map((entry) => entry.trim().split("="))
.find(([code]) => code === assetCode)?.[1];
const precision = Number.isInteger(Number(decimals))
? Number(decimals)
: 7;
const precision = Number.isInteger(Number(decimals)) ? Number(decimals) : 7;
if (precision < 0 || precision > 18) {
throw new Error(`Invalid decimal precision configured for ${assetCode}`);
}
return dec.times(new Prisma.Decimal(10).pow(precision)).toFixed(
0,
Prisma.Decimal.ROUND_HALF_UP,
);
return dec
.times(new Prisma.Decimal(10).pow(precision))
.toFixed(0, Prisma.Decimal.ROUND_HALF_UP);
}

private resolveMemoId(rawMemo: string, memoPrefix: string): string | null {
Expand Down
Loading
Loading