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
6 changes: 3 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ DB_IDLE_TIMEOUT_MS=30000 # close idle connections after 30s
DB_CONNECTION_TIMEOUT_MS=5000 # fail if no connection available within 5s

# Authentication
# Secret used for signing JWT tokens
# Secret used for signing JWT tokens (minimum 32 characters)
JWT_SECRET=your_jwt_secret_here

# Stellar Network
# The Stellar network to use (testnet | public)
# The Stellar network to use (must be one of: testnet | mainnet)
STELLAR_NETWORK=testnet

# Platform Secrets
# Secret key for the platform's Stellar account
# Secret key for the platform's Stellar account (56-char Stellar secret seed starting with S)
PLATFORM_SECRET_KEY=your_platform_secret_key_here

# Asset Issuance
Expand Down
5 changes: 4 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# ── Server ───────────────────────────────────────────────
# Integer between 1 and 65535
PORT=3001
NODE_ENV=development

Expand All @@ -11,6 +12,7 @@ DB_CONNECTION_TIMEOUT_MS=5000 # fail if no connection available within 5s

# ── Auth ─────────────────────────────────────────────────
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Minimum 32 characters required.
JWT_SECRET=replace-with-a-random-256-bit-secret
JWT_EXPIRES_IN=7d

Expand All @@ -19,7 +21,7 @@ JWT_EXPIRES_IN=7d
API_KEY_PEPPER=replace-with-a-different-random-256-bit-secret

# ── Stellar ───────────────────────────────────────────────
# 'testnet' for development, 'mainnet' for production
# Must be one of: testnet, mainnet
STELLAR_NETWORK=testnet
STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org

Expand All @@ -43,6 +45,7 @@ USDC_ISSUER=GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5

# ── Platform wallet ───────────────────────────────────────
# The Stellar secret key for the platform co-signer account
# (56-char Stellar secret seed starting with S)
# Generate a new keypair: https://laboratory.stellar.org/#account-creator
PLATFORM_SECRET_KEY=replace-with-platform-stellar-secret

Expand Down
16 changes: 16 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"express-validator": "^7.0.1",
"file-type": "^16.5.4",
"helmet": "^8.1.0",
"husky": "^9.1.7",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3",
Expand Down
10 changes: 7 additions & 3 deletions backend/src/config/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const STORAGE_VARS = ['STORAGE_BUCKET', 'STORAGE_ENDPOINT'];
function validateEnv() {
const missing = REQUIRED.filter((key) => !process.env[key]);
const errors = [];
const warnings = [];

if (missing.length) {
const list = missing.map((k) => ` - ${k}`).join('\n');
Expand All @@ -30,9 +31,7 @@ function validateEnv() {
}

if (process.env.JWT_SECRET.length < 32) {
errors.push(
'JWT_SECRET must be at least 32 characters (generate with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))")'
);
warnings.push('JWT_SECRET should be at least 32 characters');
}

const network = process.env.STELLAR_NETWORK;
Expand Down Expand Up @@ -77,6 +76,11 @@ function validateEnv() {
process.exit(1);
}

if (warnings.length) {
const list = warnings.map((w) => ` - ${w}`).join('\n');
process.stderr.write(`\n[crowdpay] Environment warnings:\n${list}\n\n`);
}

try {
validateWalletSecretConfig();
} catch (err) {
Expand Down
158 changes: 158 additions & 0 deletions backend/src/config/env.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const proxyquire = require('proxyquire').noCallThru();

const VALID_JWT_SECRET = 'a'.repeat(64);
const VALID_PLATFORM_SECRET_KEY = 'SCVMQUS5EMTHWBLJTE5XCSCMHB2ZOVKRR4ATVTRPUNRCOGKRENIL3LHR';

let exitCalls;
let stderrChunks;
let originalExit;
let originalStderrWrite;
let savedEnv;

function setupEnv(overrides = {}) {
const base = {
DATABASE_URL: 'postgres://crowdpay:crowdpay@localhost:5432/crowdpay',
JWT_SECRET: VALID_JWT_SECRET,
API_KEY_PEPPER: 'b'.repeat(64),
PLATFORM_SECRET_KEY: VALID_PLATFORM_SECRET_KEY,
STELLAR_NETWORK: 'testnet',
STELLAR_HORIZON_URL: 'https://horizon-testnet.stellar.org',
WALLET_SECRET_LOCAL_KEK: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
};
const merged = { ...base, ...overrides };
const removed = Object.keys(merged).filter((k) => merged[k] === undefined);
removed.forEach((k) => delete process.env[k]);
for (const key of Object.keys(merged)) {
if (merged[key] !== undefined) process.env[key] = merged[key];
}
}

function captureStderrAndExit() {
exitCalls = [];
stderrChunks = [];
originalExit = process.exit;
originalStderrWrite = process.stderr.write;
process.exit = (code) => {
exitCalls.push(code);
throw new Error('__EXIT__');
};
process.stderr.write = (chunk) => {
stderrChunks.push(String(chunk));
return true;
};
}

function restoreStderrAndExit() {
process.exit = originalExit;
process.stderr.write = originalStderrWrite;
}

function stubbedValidateEnv() {
return proxyquire('../config/env', {
'../services/walletSecrets': {
validateWalletSecretConfig: () => {},
},
}).validateEnv;
}

function runValidateEnv(overrides) {
setupEnv(overrides);
captureStderrAndExit();
const validateEnv = stubbedValidateEnv();
try {
validateEnv();
} catch (err) {
if (!exitCalls.length) throw err;
}
restoreStderrAndExit();
return { exitCalls: [...exitCalls], stderr: stderrChunks.join('') };
}

test.beforeEach(() => {
savedEnv = { ...process.env };
});

test.afterEach(() => {
restoreStderrAndExit();
for (const key of Object.keys(process.env)) {
if (!(key in savedEnv)) delete process.env[key];
}
for (const key of Object.keys(savedEnv)) {
process.env[key] = savedEnv[key];
}
});

test('exits with code 1 when a required variable is missing', () => {
const { exitCalls, stderr } = runValidateEnv({ JWT_SECRET: undefined });
assert.deepEqual(exitCalls, [1]);
assert.match(stderr, /missing required environment variables/);
assert.match(stderr, /JWT_SECRET/);
});

test('does not exit when JWT_SECRET meets the minimum length', () => {
const { exitCalls } = runValidateEnv();
assert.deepEqual(exitCalls, []);
});

test('warns without exiting when JWT_SECRET is shorter than 32 characters', () => {
const { exitCalls, stderr } = runValidateEnv({ JWT_SECRET: 'too-short' });
assert.deepEqual(exitCalls, []);
assert.match(stderr, /JWT_SECRET should be at least 32 characters/);
});

test('exits when STELLAR_NETWORK is not testnet or mainnet', () => {
const { exitCalls, stderr } = runValidateEnv({ STELLAR_NETWORK: 'public' });
assert.deepEqual(exitCalls, [1]);
assert.match(stderr, /STELLAR_NETWORK must be one of: testnet, mainnet/);
});

test('exits when PLATFORM_SECRET_KEY does not start with S', () => {
const { exitCalls, stderr } = runValidateEnv({
PLATFORM_SECRET_KEY: 'GCVMQUS5EMTHWBLJTE5XCSCMHB2ZOVKRR4ATVTRPUNRCOGKRENIL3LHR',
});
assert.deepEqual(exitCalls, [1]);
assert.match(stderr, /PLATFORM_SECRET_KEY must be a valid Stellar secret seed/);
});

test('exits when PLATFORM_SECRET_KEY is not 56 characters', () => {
const { exitCalls, stderr } = runValidateEnv({
PLATFORM_SECRET_KEY: 'S' + 'A'.repeat(50),
});
assert.deepEqual(exitCalls, [1]);
assert.match(stderr, /PLATFORM_SECRET_KEY must be a valid Stellar secret seed/);
});

test('exits when PORT is not a number', () => {
const { exitCalls, stderr } = runValidateEnv({ PORT: 'abc' });
assert.deepEqual(exitCalls, [1]);
assert.match(stderr, /PORT must be an integer between 1 and 65535/);
});

test('exits when PORT is out of range', () => {
const { exitCalls, stderr } = runValidateEnv({ PORT: '70000' });
assert.deepEqual(exitCalls, [1]);
assert.match(stderr, /PORT must be an integer between 1 and 65535/);
});

test('does not exit when PORT is unset', () => {
const { exitCalls } = runValidateEnv({ PORT: undefined });
assert.deepEqual(exitCalls, []);
});

test('does not exit with a fully valid configuration', () => {
const { exitCalls, stderr } = runValidateEnv({ PORT: '3001' });
assert.deepEqual(exitCalls, []);
assert.doesNotMatch(stderr, /Cannot start/);
});

test('exits when API_KEY_PEPPER equals JWT_SECRET', () => {
const shared = 'c'.repeat(64);
const { exitCalls, stderr } = runValidateEnv({
JWT_SECRET: shared,
API_KEY_PEPPER: shared,
});
assert.deepEqual(exitCalls, [1]);
assert.match(stderr, /API_KEY_PEPPER must differ from JWT_SECRET/);
});
2 changes: 1 addition & 1 deletion backend/src/routes/admin.impersonation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const express = require('express');
const jwt = require('jsonwebtoken');
const proxyquire = require('proxyquire').noCallThru();

process.env.JWT_SECRET = process.env.JWT_SECRET || 'testsecret';
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-impersonation-unit-test-jwt-secret-32';

let queryCalls;

Expand Down
2 changes: 1 addition & 1 deletion backend/src/routes/contributions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const { TX_TIMEOUT_CONTRIBUTION_S } = require('../config/constants');
// in environments (e.g. CI, unit tests) where USDC_ISSUER is not set.
process.env.USDC_ISSUER = process.env.USDC_ISSUER || 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5';
// Provide a dummy JWT_SECRET for token signing/verification in unit tests.
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-for-unit-tests';
process.env.JWT_SECRET = process.env.JWT_SECRET || 'unit-test-jwt-secret-for-contributions-32';

const TESTNET_PASSPHRASE = Networks.TESTNET;
const VALID_G = 'GASXEYHSSVN3WSHD4WSZ4O37HC2AG4JH2EB6UPHM6IXDXDRJRDJD4RZK';
Expand Down
2 changes: 1 addition & 1 deletion playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { defineConfig, devices } from '@playwright/test';
const backendEnv = {
NODE_ENV: 'development',
PORT: '3001',
JWT_SECRET: 'testsecret',
JWT_SECRET: 'playwright-e2e-jwt-secret-do-not-use-in-prod-32char',
STELLAR_NETWORK: 'testnet',
USDC_ISSUER: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5',
PLATFORM_SECRET_KEY: 'SCVMQUS5EMTHWBLJTE5XCSCMHB2ZOVKRR4ATVTRPUNRCOGKRENIL3LHR',
Expand Down