From 180435d3adee3afcfc37feb5dcc59557131a16ad Mon Sep 17 00:00:00 2001 From: Muhammad Date: Mon, 19 Oct 2026 14:16:53 +0100 Subject: [PATCH] docs(templates): add API Service Stellar config validation examples (fixes #147) --- templates/api-service/.env.example | 12 ++ templates/api-service/README.md | 23 +++- templates/api-service/src/config.js | 58 ++++++-- templates/api-service/test/config.test.js | 153 ++++++++++++++++++++++ tests/api-service-template.test.ts | 12 ++ 5 files changed, 242 insertions(+), 16 deletions(-) create mode 100644 templates/api-service/test/config.test.js diff --git a/templates/api-service/.env.example b/templates/api-service/.env.example index bcdb776..5717324 100644 --- a/templates/api-service/.env.example +++ b/templates/api-service/.env.example @@ -1,5 +1,17 @@ +# Service listener configuration API_HOST=127.0.0.1 API_PORT=3000 + +# Stellar network configuration (defaults to official public Testnet) STELLAR_NETWORK=TESTNET STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org STELLAR_RPC_URL=https://soroban-testnet.stellar.org + +# Example: Local standalone network (e.g. stellar/quickstart container) +# STELLAR_NETWORK=STANDALONE +# STELLAR_HORIZON_URL=http://127.0.0.1:8000 +# STELLAR_RPC_URL=http://127.0.0.1:8000/rpc + +# Security notes: +# - Remote endpoints MUST use HTTPS. Plain HTTP is only permitted for localhost/127.0.0.1. +# - Never store secret keys, seed phrases, or private signing credentials in this file. diff --git a/templates/api-service/README.md b/templates/api-service/README.md index c5b5736..fe0bb97 100644 --- a/templates/api-service/README.md +++ b/templates/api-service/README.md @@ -30,15 +30,24 @@ cp .env.example .env node --env-file=.env src/start.js ``` -Supported values: +Supported values and defaults: -- `API_HOST` -- `API_PORT` -- `STELLAR_NETWORK` -- `STELLAR_HORIZON_URL` -- `STELLAR_RPC_URL` +- `API_HOST` — local interface to bind (default: `127.0.0.1`); +- `API_PORT` — port number between 1 and 65535 (default: `3000`); +- `STELLAR_NETWORK` — network identifier such as `TESTNET`, `FUTURENET`, or `STANDALONE` (default: `TESTNET`); +- `STELLAR_HORIZON_URL` — Horizon API endpoint (default: `https://horizon-testnet.stellar.org`); +- `STELLAR_RPC_URL` — Soroban RPC endpoint (default: `https://soroban-testnet.stellar.org`). -`.env` is ignored by Git. Never commit secret keys, seed phrases, signing material, or production credentials. Remote Stellar endpoints must use HTTPS; plain HTTP is allowed only for localhost development. +### Validation rules + +Configuration is validated at startup via `src/config.js`: + +- Remote Stellar endpoints must use HTTPS; plain HTTP is allowed only for `localhost` and `127.0.0.1` development (e.g. `http://127.0.0.1:8000`); +- Endpoint URLs must be valid absolute URLs and cannot contain embedded credentials or secrets; +- `API_PORT` must be a valid integer port between 1 and 65535; +- `STELLAR_NETWORK` and `API_HOST` must not be empty. + +`.env` is ignored by Git. Never commit secret keys, seed phrases, signing material, or production credentials. ## Check and test diff --git a/templates/api-service/src/config.js b/templates/api-service/src/config.js index f325e3c..6cc3899 100644 --- a/templates/api-service/src/config.js +++ b/templates/api-service/src/config.js @@ -8,10 +8,30 @@ const DEFAULTS = Object.freeze({ rpcUrl: 'https://soroban-testnet.stellar.org', }); +function parseHost(value) { + const host = (value ?? DEFAULTS.host).trim(); + + if (host.length === 0) { + throw new Error('API_HOST must not be empty.'); + } + + return host; +} + function parsePort(value) { - const port = Number.parseInt(value ?? String(DEFAULTS.port), 10); + if (value === undefined || value === null || value === '') { + return DEFAULTS.port; + } + + const trimmed = String(value).trim(); + const port = Number.parseInt(trimmed, 10); - if (!Number.isInteger(port) || port < 1 || port > 65535) { + if ( + !Number.isInteger(port) || + port < 1 || + port > 65535 || + String(port) !== trimmed + ) { throw new Error('API_PORT must be an integer between 1 and 65535.'); } @@ -19,7 +39,16 @@ function parsePort(value) { } function parseUrl(name, value) { - const url = new URL(value); + let url; + try { + url = new URL(value); + } catch { + throw new Error(`${name} must be a valid absolute URL.`); + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new Error(`${name} must use HTTPS or HTTP protocol.`); + } if ( url.protocol !== 'https:' && @@ -29,21 +58,32 @@ function parseUrl(name, value) { throw new Error(`${name} must use HTTPS unless it targets localhost.`); } + if (url.username || url.password) { + throw new Error(`${name} must not contain embedded user credentials.`); + } + return url.toString().replace(/\/$/, ''); } +function parseNetwork(value) { + const network = (value ?? DEFAULTS.network).trim(); + + if (network.length === 0) { + throw new Error('STELLAR_NETWORK must not be empty.'); + } + + return network; +} + export function getConfig(env = globalThis.process.env) { return { - host: env.API_HOST ?? DEFAULTS.host, + host: parseHost(env.API_HOST), port: parsePort(env.API_PORT), - network: env.STELLAR_NETWORK ?? DEFAULTS.network, + network: parseNetwork(env.STELLAR_NETWORK), horizonUrl: parseUrl( 'STELLAR_HORIZON_URL', env.STELLAR_HORIZON_URL ?? DEFAULTS.horizonUrl, ), - rpcUrl: parseUrl( - 'STELLAR_RPC_URL', - env.STELLAR_RPC_URL ?? DEFAULTS.rpcUrl, - ), + rpcUrl: parseUrl('STELLAR_RPC_URL', env.STELLAR_RPC_URL ?? DEFAULTS.rpcUrl), }; } diff --git a/templates/api-service/test/config.test.js b/templates/api-service/test/config.test.js new file mode 100644 index 0000000..c09ee4d --- /dev/null +++ b/templates/api-service/test/config.test.js @@ -0,0 +1,153 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { getConfig } from '../src/config.js'; +import { + createStellarBoundary, + getStellarNetworkDescriptor, +} from '../src/stellar-client.js'; + +test('default configuration uses accurate public Stellar Testnet endpoints', () => { + const config = getConfig({}); + + assert.equal(config.host, '127.0.0.1'); + assert.equal(config.port, 3000); + assert.equal(config.network, 'TESTNET'); + assert.equal(config.horizonUrl, 'https://horizon-testnet.stellar.org'); + assert.equal(config.rpcUrl, 'https://soroban-testnet.stellar.org'); +}); + +test('accepts valid custom remote HTTPS Stellar endpoints and normalizes trailing slashes', () => { + const config = getConfig({ + STELLAR_NETWORK: 'FUTURENET', + STELLAR_HORIZON_URL: 'https://horizon-futurenet.stellar.org/', + STELLAR_RPC_URL: 'https://rpc-futurenet.stellar.org/soroban/rpc/', + }); + + assert.equal(config.network, 'FUTURENET'); + assert.equal(config.horizonUrl, 'https://horizon-futurenet.stellar.org'); + assert.equal(config.rpcUrl, 'https://rpc-futurenet.stellar.org/soroban/rpc'); +}); + +test('accepts plain HTTP endpoints for localhost and 127.0.0.1 development', () => { + const localhostConfig = getConfig({ + STELLAR_NETWORK: 'STANDALONE', + STELLAR_HORIZON_URL: 'http://localhost:8000', + STELLAR_RPC_URL: 'http://localhost:8000/rpc', + }); + + assert.equal(localhostConfig.horizonUrl, 'http://localhost:8000'); + assert.equal(localhostConfig.rpcUrl, 'http://localhost:8000/rpc'); + + const loopbackConfig = getConfig({ + STELLAR_NETWORK: 'STANDALONE', + STELLAR_HORIZON_URL: 'http://127.0.0.1:8000', + STELLAR_RPC_URL: 'http://127.0.0.1:8000/rpc', + }); + + assert.equal(loopbackConfig.horizonUrl, 'http://127.0.0.1:8000'); + assert.equal(loopbackConfig.rpcUrl, 'http://127.0.0.1:8000/rpc'); +}); + +test('rejects malformed URL strings with clear field-specific errors', () => { + assert.throws( + () => getConfig({ STELLAR_HORIZON_URL: 'not-a-valid-url' }), + (error) => { + assert.match(error.message, /STELLAR_HORIZON_URL/); + assert.match(error.message, /valid absolute URL/); + return true; + }, + ); + + assert.throws( + () => getConfig({ STELLAR_RPC_URL: 'relative/path/rpc' }), + (error) => { + assert.match(error.message, /STELLAR_RPC_URL/); + assert.match(error.message, /valid absolute URL/); + return true; + }, + ); +}); + +test('rejects remote Stellar endpoints using insecure plain HTTP', () => { + assert.throws( + () => + getConfig({ STELLAR_HORIZON_URL: 'http://horizon-testnet.stellar.org' }), + /STELLAR_HORIZON_URL must use HTTPS unless it targets localhost\./, + ); + + assert.throws( + () => getConfig({ STELLAR_RPC_URL: 'http://example.com:8000/rpc' }), + /STELLAR_RPC_URL must use HTTPS unless it targets localhost\./, + ); +}); + +test('rejects non-HTTP/HTTPS protocols even on localhost', () => { + assert.throws( + () => getConfig({ STELLAR_RPC_URL: 'ftp://localhost:8000' }), + /STELLAR_RPC_URL must use HTTPS or HTTP protocol\./, + ); + + assert.throws( + () => getConfig({ STELLAR_HORIZON_URL: 'file:///etc/hosts' }), + /STELLAR_HORIZON_URL must use HTTPS or HTTP protocol\./, + ); +}); + +test('rejects URLs containing embedded credentials or secrets', () => { + assert.throws( + () => + getConfig({ + STELLAR_HORIZON_URL: + 'https://user:secret-token@horizon-testnet.stellar.org', + }), + /STELLAR_HORIZON_URL must not contain embedded user credentials\./, + ); + + assert.throws( + () => + getConfig({ + STELLAR_RPC_URL: 'https://apikey@soroban-testnet.stellar.org', + }), + /STELLAR_RPC_URL must not contain embedded user credentials\./, + ); +}); + +test('validates API port boundaries and rejects malformed values', () => { + assert.throws(() => getConfig({ API_PORT: '0' }), /API_PORT/); + assert.throws(() => getConfig({ API_PORT: '65536' }), /API_PORT/); + assert.throws(() => getConfig({ API_PORT: '-1' }), /API_PORT/); + assert.throws(() => getConfig({ API_PORT: '3000abc' }), /API_PORT/); + assert.throws(() => getConfig({ API_PORT: 'abc' }), /API_PORT/); + + const customPort = getConfig({ API_PORT: '8080' }); + assert.equal(customPort.port, 8080); +}); + +test('rejects empty network or host strings', () => { + assert.throws( + () => getConfig({ STELLAR_NETWORK: ' ' }), + /STELLAR_NETWORK must not be empty\./, + ); + + assert.throws( + () => getConfig({ API_HOST: ' ' }), + /API_HOST must not be empty\./, + ); +}); + +test('Stellar boundary contains only public descriptors with no signing secrets', () => { + const config = getConfig({}); + const boundary = createStellarBoundary(config); + const descriptor = getStellarNetworkDescriptor(boundary); + + assert.deepEqual(Object.keys(descriptor).sort(), [ + 'horizonUrl', + 'network', + 'rpcUrl', + ]); + assert.equal(Object.isFrozen(boundary), true); + + // Assert no secret keys or seed phrases are exposed on the boundary or config + const serialized = JSON.stringify({ config, descriptor }); + assert.doesNotMatch(serialized, /secret|seed|private[_-]?key/i); +}); diff --git a/tests/api-service-template.test.ts b/tests/api-service-template.test.ts index d981106..c1cdebb 100644 --- a/tests/api-service-template.test.ts +++ b/tests/api-service-template.test.ts @@ -40,6 +40,9 @@ describe('API Service template', () => { expect(existsSync(join(projectRoot, 'test', 'server.test.js'))).toBe( true, ); + expect(existsSync(join(projectRoot, 'test', 'config.test.js'))).toBe( + true, + ); expect(captured.stdoutText()).toContain( 'Created demo-api from api-service', ); @@ -58,6 +61,8 @@ describe('API Service template', () => { expect(environmentExample).toContain('STELLAR_NETWORK=TESTNET'); expect(environmentExample).toContain('STELLAR_HORIZON_URL=https://'); expect(environmentExample).toContain('STELLAR_RPC_URL=https://'); + expect(environmentExample).toContain('STELLAR_NETWORK=STANDALONE'); + expect(environmentExample).toContain('http://127.0.0.1:8000'); expect(environmentExample).not.toMatch(/SECRET|SEED|PRIVATE_KEY/); const check = runNpmScript(projectRoot, 'check'); @@ -74,6 +79,13 @@ describe('API Service template', () => { expect(generatedReadme).toContain('src/stellar-client.js'); expect(generatedReadme).toContain('Never commit secret keys'); expect(generatedReadme).toContain('official Stellar documentation'); + expect(generatedReadme).toContain('Validation rules'); + expect(generatedReadme).toContain( + 'https://horizon-testnet.stellar.org', + ); + expect(generatedReadme).toContain( + 'https://soroban-testnet.stellar.org', + ); }), 15_000, );