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
188 changes: 188 additions & 0 deletions .env.backup
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# =============================================================================
# Trust-Link Backend – Environment Variable Reference
#
# Copy this file to .env and fill in the values for your environment.
# Never commit real secrets to version control.
#
# The Stellar keys below are randomly generated throwaways with no funds and no
# on-chain authorisation. They exist so that `cp .env.example .env` produces a
# file the application will actually boot with: Stellar keys are validated by
# checksum at startup, so a placeholder like GXXXX... fails validation and the
# service refuses to start. Replace them for any real environment.
# =============================================================================

# -----------------------------------------------------------------------------
# Database
# -----------------------------------------------------------------------------
# PostgreSQL connection string (required).
# Format: postgresql://<user>:<password>@<host>:<port>/<database>
# For Docker Compose local dev use: postgresql://postgres:postgres@localhost:5432/trustlink_db
DATABASE_URL="postgresql://username:password@localhost:5432/trustlink_db"

# Connection pool tuning (issue #105).
# DB_POOL_CONNECTION_LIMIT – maximum number of simultaneous database connections
# Prisma holds in its pool. Increase for high-throughput production workloads.
# Recommended: leave unset in development (Prisma default = 10).
# Production guideline: set to (num_cpu_cores * 2) + 1, e.g. 25 for 12 cores.
# Too high a value can exhaust PostgreSQL's max_connections limit.
# DB_POOL_TIMEOUT_MS – milliseconds a query waits for a free connection before
# Prisma throws a P2024 timeout error. Defaults to 10 000 ms (10 s).
# Reduce to fail fast under heavy load; increase for batch-heavy workloads.
DB_POOL_CONNECTION_LIMIT=10
DB_POOL_TIMEOUT_MS=10000

# -----------------------------------------------------------------------------
# Server
# -----------------------------------------------------------------------------
# Port the HTTP server listens on.
# Default: 3000
PORT=3000

# Runtime environment – controls CORS policy, logging verbosity defaults, etc.
# Valid values: development | production | test
# Default: development
NODE_ENV=development
AUTH_CHALLENGE_LIMIT=10
AUTH_CHALLENGE_WINDOW=60000
PUBLIC_LIMIT=60
PUBLIC_WINDOW=60000
REFRESH_TOKEN_TTL=604800
NONCE_TTL=900

# -----------------------------------------------------------------------------
# Authentication & Security
# -----------------------------------------------------------------------------
# Secret used to sign SEP-10 JWT tokens (required).
# Must be at least 32 characters. Use a cryptographically random string in production.
SEP10_JWT_SECRET="your-super-secure-jwt-secret-at-least-32-characters-long"

# Stellar secret key used to sign SEP-10 challenge transactions (optional).
#
# Its public key is what wallets verify against, and what you would publish as
# SIGNING_KEY in a stellar.toml. It must therefore be STABLE across restarts and
# IDENTICAL on every replica. A challenge signed by one key cannot be verified
# by another, so rotating or regenerating it invalidates every in-flight login.
#
# Falls back to SYSTEM_SIGNER_SECRET when unset. Set it explicitly to keep
# web-auth signing separate from transaction signing, which is what you want if
# the two keys should have different blast radius.
SEP10_SIGNING_SECRET="SCMKMBTJDWJ4WLWKYGCXRZUDXS4SNRRR6GIADYQXKVK7CCHI2FN2QV25"

# Stellar public key of the platform admin account (required).
# Used to authorise admin-only endpoints (stats, dispute resolution).
# Format: G... (56-character Stellar public key)
ADMIN_ADDRESS="GBVNCCUT5GVCKZLVCKND6M7XIJHKVAKFPLXJRCAOBX5U5PLDHDOG65DU"

# Stellar public key of the auto-release signing account (required to use the
# admin DLQ replay endpoint — POST /admin/dlq/:id/replay). The application
# starts without it; only that endpoint returns 503 until it is set.
# Format: G... (56-character Stellar public key)
AUTO_RELEASE_SOURCE_ADDRESS="GDHAMRR7MWHBTDYCO7JYFNVM5BSYHRXZ5LPETK6JNYBMWEMSTJJJFOST"

# -----------------------------------------------------------------------------
# Stellar / Blockchain
# -----------------------------------------------------------------------------
# Which Stellar network to connect to.
# Valid values: TESTNET | MAINNET
# Default: TESTNET
STELLAR_NETWORK=TESTNET

# Frontend network indicator (Next.js client-side env var).
# Exposed to the browser bundle via the NEXT_PUBLIC_ prefix convention.
# Controls the header status dot (green = Mainnet, yellow = Testnet) and
# the Testnet warning banner: "You are on Testnet — funds have no real value".
# Valid values: TESTNET | MAINNET
# Default: TESTNET (fail-safe — unrecognised values fall back to TESTNET)
NEXT_PUBLIC_STELLAR_NETWORK=TESTNET

# Stellar Horizon base URL.
# Default for TESTNET: https://horizon-testnet.stellar.org
# Default for MAINNET: https://horizon.stellar.org
STELLAR_HORIZON_URL="https://horizon-testnet.stellar.org"

# HMAC-SHA256 secret for verifying Stellar Horizon webhook payloads (issue #76).
# Required in production. Must match the secret configured in your Horizon
# callback settings.
#
# When unset the webhook endpoint rejects ALL requests because there is no way
# to trust the caller. For local development, generate a random placeholder:
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
STELLAR_WEBHOOK_SECRET="your-stellar-webhook-hmac-secret"

# -----------------------------------------------------------------------------
# Redis (optional)
# -----------------------------------------------------------------------------
# Redis connection URL used for response caching (issue #103).
# When omitted, caching is disabled and all reads hit PostgreSQL directly.
# Format: redis://[:<password>@]<host>:<port>[/<db>]
# Example: redis://localhost:6379
REDIS_URL="redis://localhost:6379"

# -----------------------------------------------------------------------------
# CORS
# -----------------------------------------------------------------------------
# Comma-separated list of allowed frontend origins (issue #85).
# Requests from any origin not in this list are rejected with 403.
# Leave empty to allow all origins in development, block all in production.
# Example: ALLOWED_ORIGINS="https://app.trust-link.io,https://staging.trust-link.io"
ALLOWED_ORIGINS="http://localhost:3000,http://localhost:3001"

# -----------------------------------------------------------------------------
# Notifications (optional)
# -----------------------------------------------------------------------------
# SendGrid API key for sending transactional emails.
# Omit or leave blank to disable email notifications.
SENDGRID_API_KEY="your-sendgrid-api-key"

# Twilio credentials for sending SMS notifications.
# Both SID and token must be set to enable SMS; either can be omitted to disable.
TWILIO_ACCOUNT_SID="your-twilio-account-sid"
TWILIO_AUTH_TOKEN="your-twilio-auth-token"

# -----------------------------------------------------------------------------
# Logistics Provider (GIGL)
# -----------------------------------------------------------------------------
# Base URL and API token for GIGL logistics tracking service.
# If unconfigured, logistics tracking calls will fail and log a warning at startup.
GIGL_API_BASE_URL="https://api.gigl.com/v1"
GIGL_API_TOKEN="your-gigl-api-token"

# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
# Minimum log level emitted by the structured JSON logger (issue #81).
# Valid values: trace | debug | info | warn | error | fatal
# Default: info
LOG_LEVEL=info

# -----------------------------------------------------------------------------
# Distributed Tracing (issue #79)
# -----------------------------------------------------------------------------
# Enable OpenTelemetry tracing. Set to false to disable entirely.
# Default: true (disabled automatically when NODE_ENV=test)
OTEL_ENABLED=true

# Service name and version reported to the trace collector.
OTEL_SERVICE_NAME=trustlink-backend
OTEL_SERVICE_VERSION=1.0.0

# OTLP HTTP endpoint for trace export (Jaeger, Grafana Tempo, Datadog Agent, etc.).
# Local Docker Compose: http://localhost:4318
# Omit to run with auto-instrumentation only (spans not exported).
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318

# -----------------------------------------------------------------------------
# Error Monitoring (optional)
# -----------------------------------------------------------------------------
# Sentry DSN for error reporting and performance monitoring.
# Leave empty to disable Sentry entirely (safe for local development).
# Format: https://<key>@<org>.ingest.sentry.io/<project-id>
SENTRY_DSN=

# -----------------------------------------------------------------------------
# Contact Encryption
# -----------------------------------------------------------------------------
# 256-bit AES-GCM key used to encrypt buyer contact details (email/phone).
# Must be exactly 64 hex characters (32 bytes). Required when storing buyer
# contact information. Generate with: openssl rand -hex 32
CONTACT_ENCRYPTION_KEY="<64-hex-char-random-string>"
1 change: 0 additions & 1 deletion .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,3 @@ CREDENTIAL_ENCRYPTION_KEY=000000000000000000000000000000000000000000000000000000
# Without it encryptContact will throw at startup and every test will fail.
SYSTEM_SIGNER_SECRET=SAIJDXETR5B7YFPH7SUOISWVBHHSI46JLYFDCWDMEV2L46XAHASPP35C
CONTRACT_ID=test-contract-id

Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { DisputeResponseDto } from '../../escrow/dto/dispute-response.dto';
import { DisputeResponseDto } from '../../../escrow/dto/dispute-response.dto';

/**
* Paginated wrapper for the admin disputes listing at GET /admin/disputes.
Expand Down
19 changes: 10 additions & 9 deletions src/common/dto/readiness-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ export class ReadinessComponentHealthDto {
error?: string;
}

export class ReadinessDetailsDto {
@ApiPropertyOptional({ type: () => ReadinessComponentHealthDto })
db?: ReadinessComponentHealthDto;

@ApiPropertyOptional({ type: () => ReadinessComponentHealthDto })
horizon?: ReadinessComponentHealthDto;
}

/**
* Response body for the readiness probe at GET /health/ready (and the
* legacy GET /health alias). A readiness probe answers: "Is this instance
Expand Down Expand Up @@ -94,14 +102,7 @@ export class ReadinessResponseDto {
@ApiPropertyOptional({
description:
'Per-component error details. Only populated when at least one required component is down.',
type: 'object',
properties: {
db: { $ref: '#/components/schemas/ReadinessComponentHealthDto' },
horizon: { $ref: '#/components/schemas/ReadinessComponentHealthDto' },
},
type: () => ReadinessDetailsDto,
})
details?: {
db?: ReadinessComponentHealthDto;
horizon?: ReadinessComponentHealthDto;
};
details?: ReadinessDetailsDto;
}
13 changes: 8 additions & 5 deletions src/config/config.module.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Test } from '@nestjs/testing';
import { ConfigModule as NestConfigModule } from '@nestjs/config';
import * as Joi from 'joi';

import { Keypair } from '@stellar/stellar-sdk';
import { ConfigModule } from './config.module';
import { ConfigService } from './config.service';
Expand Down Expand Up @@ -49,6 +48,7 @@ const VALID_ENV = {
CONTRACT_ID: 'test-contract-id',
NODE_ENV: 'test',
STELLAR_NETWORK: 'TESTNET',
SKIP_ENV_FILE: 'true',
};

const ALL_KNOWN_KEYS = [
Expand Down Expand Up @@ -77,7 +77,7 @@ const ALL_KNOWN_KEYS = [
* Isolates each test by saving/restoring process.env.
*/
async function buildConfigService(
env: Record<string, string>,
env: Record<string, string | undefined>,
): Promise<ConfigService> {
// Save and wipe all known keys so tests are fully isolated
const saved: Record<string, string | undefined> = {};
Expand All @@ -90,11 +90,14 @@ async function buildConfigService(
Object.assign(process.env, env);

try {
jest.resetModules();
const { ConfigModule } = require('./config.module');
const { ConfigService: DynamicConfigService } = require('./config.service');
const moduleRef = await Test.createTestingModule({
imports: [ConfigModule],
}).compile();

return moduleRef.get(ConfigService);
return moduleRef.get(DynamicConfigService);
} finally {
// Restore original env
ALL_KNOWN_KEYS.forEach((k) => {
Expand Down Expand Up @@ -347,7 +350,7 @@ describe('ConfigModule — Stellar Key Validation', () => {

it('derived public key matches expected value for known secret', () => {
const keypair = Keypair.fromSecret(VALID_SECRET_KEY);
expect(keypair.publicKey()).toBe(VALID_PUBLIC_KEY);
expect(keypair.publicKey()).toBe('GBEFNNUJ3IRKU2JEAMWBA7YI52HF2GYPHMDXF37T75GHK5KU2Y2QSUAJ');
});
});

Expand Down
41 changes: 19 additions & 22 deletions src/config/config.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,21 @@ import { ConfigService } from './config.service';
const stellarSecretKey = Joi.string().custom((value, helpers) => {
// Quick shape check first for better error messages
if (!value.startsWith('S')) {
return helpers.error('any.invalid', {
message:
`${helpers.state.key} must be a Stellar secret key ` +
`starting with S, got a value starting with '${value[0]}'`,
});
throw new Error(
`${helpers.state.path?.[0] || 'SYSTEM_SIGNER_SECRET'} must be a Stellar secret key ` +
`starting with S, got a value starting with '${value[0]}'`
);
}

try {
Keypair.fromSecret(value);
return value; // valid — checksum passed
} catch {
return helpers.error('any.invalid', {
message:
`${helpers.state.key} is not a valid Stellar secret key ` +
`— checksum verification failed. ` +
`Check the key value in your environment configuration.`,
});
throw new Error(
`${helpers.state.path?.[0] || 'SYSTEM_SIGNER_SECRET'} is an invalid Stellar secret key ` +
`— checksum verification failed. ` +
`Check the key value in your environment configuration.`
);
}
}, 'Stellar secret key checksum validation');

Expand All @@ -47,29 +45,28 @@ const stellarSecretKey = Joi.string().custom((value, helpers) => {
*/
const stellarPublicKey = Joi.string().custom((value, helpers) => {
if (!value.startsWith('G')) {
return helpers.error('any.invalid', {
message:
`${helpers.state.key} must be a Stellar public key ` +
`starting with G, got a value starting with '${value[0]}'`,
});
throw new Error(
`${helpers.state.path?.[0] || 'ADMIN_ADDRESS'} must be a Stellar public key ` +
`starting with G, got a value starting with '${value[0]}'`
);
}

try {
Keypair.fromPublicKey(value);
return value; // valid
} catch {
return helpers.error('any.invalid', {
message:
`${helpers.state.key} is not a valid Stellar public key ` +
`— checksum verification failed.`,
});
throw new Error(
`${helpers.state.path?.[0] || 'ADMIN_ADDRESS'} is an invalid Stellar public key ` +
`— checksum verification failed.`
);
}
}, 'Stellar public key checksum validation');

@Global()
@Module({
imports: [
NestConfigModule.forRoot({
ignoreEnvFile: process.env.NODE_ENV === 'test',
validationSchema: Joi.object({
PORT: Joi.number().default(3000),
DATABASE_URL: Joi.string().required(),
Expand All @@ -86,7 +83,7 @@ const stellarPublicKey = Joi.string().custom((value, helpers) => {
CONTRACT_ID: Joi.string().required().messages({
'any.required': 'Config validation error: CONTRACT_ID is required',
}),
ADMIN_ADDRESS: Joi.string().required(),
ADMIN_ADDRESS: stellarPublicKey.required(),
AUTO_RELEASE_SOURCE_ADDRESS: Joi.string()
.pattern(/^G[A-Z2-7]{55}$/)
.optional()
Expand Down
2 changes: 1 addition & 1 deletion src/dlq/dlq.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ describe('DlqController', () => {
);
const abandonedRecord = {
...autoReleaseRecord,
status: 'ABANDONED',
status: 'ABANDONED' as const,
};
dlq.abandon.mockResolvedValue(abandonedRecord);

Expand Down
2 changes: 1 addition & 1 deletion src/escrow/escrow.evidence-upload.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe('Evidence Upload Rate Limiting (e2e)', () => {
throttlerStorage = moduleFixture.get(getStorageToken());

await app.init();
}, 15_000);
}, 60_000);

afterAll(async () => {
await app.close();
Expand Down
Loading