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 templates/api-service/.env.example
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 16 additions & 7 deletions templates/api-service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
58 changes: 49 additions & 9 deletions templates/api-service/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,47 @@ 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.');
}

return port;
}

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:' &&
Expand All @@ -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),
};
}
153 changes: 153 additions & 0 deletions templates/api-service/test/config.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
12 changes: 12 additions & 0 deletions tests/api-service-template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
Expand All @@ -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');
Expand All @@ -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,
);
Expand Down
Loading