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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Accepted values: TESTNET or PUBLIC (Stellar SDK canonical name for mainnet).
# "MAINNET" is also accepted as a legacy alias for "PUBLIC".
# An unrecognized value causes the app to fail fast at startup.
STELLAR_NETWORK=TESTNET
STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org
SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
Expand Down Expand Up @@ -46,6 +49,15 @@ INFURA_IPFS_PROJECT_ID=
INFURA_IPFS_PROJECT_SECRET=
IPFS_REPIN_INTERVAL_MS=300000

# Maximum request body size for JSON payloads (in MB).
# The IPFS pin endpoint accepts base64-encoded content; 15 MB provides headroom
# for a 10 MB decoded deliverable (~13.6 MB base64) plus JSON envelope overhead.
BODY_LIMIT_MB=15

# Webhook outgoing request timeout (ms). Requests that exceed this are aborted
# rather than hanging indefinitely. Defaults to 10 seconds.
WEBHOOK_TIMEOUT_MS=10000

# Gig expiry sweep — marks unanswered gig solicitations expired past their response deadline
GIG_EXPIRY_SWEEP_INTERVAL_MS=300000
# How long a paginated GET /gigs search result page stays cached in Redis
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ See [Backend Setup Instructions](backend/SETUP_INSTRUCTIONS.md) for detailed dev

Full guide: [API Documentation](backend/API_DOCUMENTATION.md)

### 🗂️ Off-Chain State Model

[docs/state-model.md](docs/state-model.md) — Reference for the Escrow, Gig, and DisputeSaga state
machines: which transitions are driven by API calls, on-chain Soroban events, or background workers,
plus a catalogue of known deviations from the intended model.

### Auth (`/auth`)

- **`GET /auth/challenge`** — Get authentication challenge for wallet signing
Expand Down
8 changes: 7 additions & 1 deletion backend/src/ipfs-pinning/ipfs-pinning.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,17 @@ import {

export class PinContentDto {
@ApiProperty({
description: 'Base64-encoded bytes of the deliverable to pin',
description:
'Base64-encoded bytes of the deliverable to pin. ' +
'Maximum decoded size is 10 MB (base64 overhead adds ~33%, so the encoded string cap is ~13.6 MB, ' +
'enforced here as 14,316,560 base64 characters).',
example: 'SGVsbG8sIFRydXN0RmxvdyE=',
})
@IsBase64()
@IsNotEmpty()
@MaxLength(14_316_560, {
message: 'content exceeds the maximum allowed size of 10 MB (decoded)',
})
content: string;

@ApiPropertyOptional({ description: 'Original filename, stored for display purposes only' })
Expand Down
10 changes: 10 additions & 0 deletions backend/src/ipfs-pinning/ipfs-pinning.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ export class IpfsPinningService {
*/
async pinContent(dto: PinContentDto): Promise<PinRecord> {
const buffer = Buffer.from(dto.content, 'base64');

// Explicit size guard (belt-and-suspenders alongside the DTO @MaxLength check and
// the Express body-size limit configured in main.ts).
const MAX_BYTES = 10 * 1024 * 1024; // 10 MB
if (buffer.length > MAX_BYTES) {
throw new BadRequestException(
`Decoded content size (${buffer.length} bytes) exceeds the maximum allowed size of 10 MB`,
);
}

const cid = computeCidV1Raw(buffer);

if (dto.expectedCid && dto.expectedCid !== cid) {
Expand Down
11 changes: 11 additions & 0 deletions backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ValidationPipe, Logger } from '@nestjs/common';
import * as Sentry from '@sentry/node';
import * as express from 'express';
import { AppModule } from './app.module';
import { SentryService } from './sentry/sentry.service';
import { SentryExceptionFilter } from './common/filters/sentry-exception.filter';
Expand Down Expand Up @@ -30,6 +31,16 @@ process.on('uncaughtException', (error: Error) => {
async function bootstrap() {
const app = await NestFactory.create(AppModule);

// Explicit body-size limit for JSON payloads.
// The IPFS pin endpoint accepts base64-encoded deliverable content; 10 MB decoded
// encodes to ~13.6 MB base64, so a 15 MB JSON limit gives adequate headroom while
// still providing a deliberate, reviewed DoS control rather than relying on Express's
// implicit default. Override via BODY_LIMIT_MB env var if your use case requires it.
const bodyLimitMb = parseInt(process.env.BODY_LIMIT_MB || '15', 10);
const bodyLimit = `${bodyLimitMb}mb`;
app.use(express.json({ limit: bodyLimit }));
app.use(express.urlencoded({ extended: true, limit: bodyLimit }));

// Initialize Sentry via the injectable service so it shares the same instance
const sentryService = app.get(SentryService);
sentryService.init();
Expand Down
39 changes: 34 additions & 5 deletions backend/src/stellar/stellar.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
/**
* Canonical accepted values for STELLAR_NETWORK:
* - "TESTNET" — Stellar Testnet (Test SDF Network ; September 2015)
* - "PUBLIC" — Stellar Mainnet, matching @stellar/stellar-sdk's Networks.PUBLIC constant
*
* The legacy value "MAINNET" is also accepted as an alias for "PUBLIC" to avoid breaking
* existing deployments, but "PUBLIC" is the preferred production value per Stellar SDK conventions.
*
* An unrecognized value causes the app to throw at startup rather than silently
* defaulting to testnet (which would be a dangerous misconfiguration in production).
*/
const rawNetwork = process.env.STELLAR_NETWORK || 'TESTNET';

// Accept "MAINNET" as an alias for "PUBLIC" (legacy compatibility)
const normalizedNetwork = rawNetwork === 'MAINNET' ? 'PUBLIC' : rawNetwork;

const VALID_NETWORKS = ['TESTNET', 'PUBLIC'] as const;
type StellarNetworkValue = (typeof VALID_NETWORKS)[number];

if (!(VALID_NETWORKS as readonly string[]).includes(normalizedNetwork)) {
throw new Error(
`Invalid STELLAR_NETWORK value: "${rawNetwork}". ` +
`Accepted values are "TESTNET" or "PUBLIC" (the Stellar SDK canonical name for mainnet). ` +
`"MAINNET" is also accepted as a legacy alias for "PUBLIC".`,
);
}

const NETWORK_PASSPHRASES: Record<StellarNetworkValue, string> = {
PUBLIC: 'Public Global Stellar Network ; September 2015',
TESTNET: 'Test SDF Network ; September 2015',
};

export const STELLAR_CONFIG = {
network: process.env.STELLAR_NETWORK || 'TESTNET',
network: normalizedNetwork as StellarNetworkValue,
// Single endpoint fallback (for backward compatibility)
horizonUrl: process.env.STELLAR_HORIZON_URL || 'https://horizon-testnet.stellar.org',
sorobanRpcUrl: process.env.SOROBAN_RPC_URL || 'https://soroban-testnet.stellar.org',
Expand All @@ -11,8 +43,5 @@ export const STELLAR_CONFIG = {
.split(',')
.map(url => url.trim()),
contractId: process.env.TRUSTFLOW_CONTRACT_ID || '',
networkPassphrase:
process.env.STELLAR_NETWORK === 'MAINNET'
? 'Public Global Stellar Network ; September 2015'
: 'Test SDF Network ; September 2015',
networkPassphrase: NETWORK_PASSPHRASES[normalizedNetwork as StellarNetworkValue],
};
10 changes: 10 additions & 0 deletions backend/src/webhook/webhook.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ interface WebhookPayload {
dedupKey?: string;
}

/**
* Timeout (ms) for outgoing webhook HTTP requests.
* Configurable via WEBHOOK_TIMEOUT_MS env var; defaults to 10 seconds.
* Prevents a single unresponsive endpoint from stalling dispatch() indefinitely.
*/
const WEBHOOK_TIMEOUT_MS = parseInt(process.env.WEBHOOK_TIMEOUT_MS || '10000', 10);

@Injectable()
export class WebhookService {
private endpoints = new Map<string, string>();
Expand Down Expand Up @@ -64,6 +71,9 @@ export class WebhookService {
else rej(new Error(`${r.statusCode}`));
},
);
req.setTimeout(WEBHOOK_TIMEOUT_MS, () => {
req.destroy(new Error(`Webhook request timed out after ${WEBHOOK_TIMEOUT_MS}ms`));
});
req.on('error', rej);
req.write(body);
req.end();
Expand Down
Loading