diff --git a/.env.example b/.env.example index e99df26..e9495e2 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,8 @@ NODE_ENV=development PORT=3000 +# Optional path every route is served under, e.g. api/v1 (GET /health stays +# at the root). Leave empty to serve from /. See docs/CONFIGURATION.md. +API_PREFIX= CORS_ORIGINS=http://localhost:3001 # Optional distributed tracing (OTLP/HTTP). See docs/TRACING.md. @@ -10,10 +13,19 @@ OTEL_TRACING_ENABLED=false # postgresql://user:password@host:5432/db DATABASE_URL= -# Must be at least 32 characters. +# Where secrets are read from: env (default), file, or custom. See the +# "Secret providers" section of docs/CONFIGURATION.md. +SECRETS_PROVIDER=env + +# Must be at least 32 characters. In production it must also not be a +# placeholder and must have 10+ distinct characters — use e.g. +# `openssl rand -base64 48`. Required when SECRETS_PROVIDER=env. JWT_SECRET= +# Path to a file holding the JWT secret. Required when SECRETS_PROVIDER=file. +# JWT_SECRET_FILE=/run/secrets/jwt_secret -# Soroban RPC endpoint (e.g. https://soroban-testnet.stellar.org) +# Soroban RPC endpoint (e.g. https://soroban-testnet.stellar.org). Boot fails +# if the URL names a different network than STELLAR_NETWORK. SOROBAN_RPC_URL= STELLAR_NETWORK=testnet diff --git a/docs/API.md b/docs/API.md index b28b2fe..fdce4b0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -6,6 +6,10 @@ services return. When they drift, the generated Bruno collection under [`bruno/`](bruno/README.md) fails CI, so treat this file and that collection as two views of the same source. +Paths are shown without a prefix. When `API_PREFIX` is set (e.g. +`api/v1`), every route except `GET /health` is served under it — see +`docs/CONFIGURATION.md`. + The examples assume: ```bash diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index bba93bc..0a2cb17 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -8,6 +8,81 @@ run with a missing or malformed value. See [`docs/NON_CUSTODIAL.md`](NON_CUSTODIAL.md) for what `JWT_SECRET` and `PLATFORM_SIGNER_SECRET` are actually used for. +## Checks beyond per-variable format + +Besides each variable's own format, `validate()` refuses to start when: + +- **`JWT_SECRET` is weak.** Every environment requires at least 32 + characters. With `NODE_ENV=production` the secret must also contain at + least 10 distinct characters and must not look like a placeholder: after + lower-casing and removing punctuation, it may not contain `changeme`, + `replaceme`, `yoursecret`, `jwtsecret`, `secretkey`, `mysecret`, + `placeholder`, `example`, `default`, `insecure`, `donotuse` or `password`. + Generate one with `openssl rand -base64 48`. The same rules apply to a + secret loaded through a [secret provider](#secret-providers). +- **`SOROBAN_RPC_URL` and `STELLAR_NETWORK` disagree.** The network + passphrase that transactions are signed with comes from `STELLAR_NETWORK`. + The app can't reach the RPC at boot, so it infers the RPC's network from + its URL instead: a host or path segment named `testnet`, `futurenet`, + `mainnet` or `pubnet` (for example `soroban-testnet.stellar.org` or + `rpc-futurenet.stellar.org`). If that network differs from + `STELLAR_NETWORK`, startup fails. URLs that name no network, or more than + one, pass this check; that covers self-hosted or local nodes such as + `http://localhost:8000/soroban/rpc`. `SOROBAN_RPC_URL` must be an `http` + or `https` URL. + +## API path prefix + +Set `API_PREFIX` (for example `api` or `api/v1`) to serve every route under +that path when the API is hosted behind a reverse proxy at a sub-path. With +`API_PREFIX=api/v1`, `POST /auth/login` becomes `POST /api/v1/auth/login`. +`GET /health` stays at the root so load-balancer and orchestrator probes +don't have to change. Leave it unset or empty to keep the current root +paths. Leading and trailing slashes are ignored. Only URL-safe path segments +are accepted, and `.` or `..` segments are rejected. + +## Secret providers + +`JWT_SECRET` is read through a pluggable `SecretProvider` +(`src/config/secrets/secret-provider.ts`), chosen with `SECRETS_PROVIDER`: + +| `SECRETS_PROVIDER` | Reads `JWT_SECRET` from | Required variables | +|---|---|---| +| `env` (default) | the `JWT_SECRET` environment variable | `JWT_SECRET` | +| `file` | the file at `JWT_SECRET_FILE`, with surrounding whitespace trimmed | `JWT_SECRET_FILE` | +| `custom` | the provider passed to `SecretsModule.forRoot({ provider })` | — | + +`file` works with Docker and Kubernetes secrets, and with secrets-manager +sidecars that mount values as files, such as Vault Agent or the AWS and GCP +Secrets Store CSI drivers. With `file`, the secret never has to be in the +process environment. + +To read the secret straight from a secrets manager instead, implement the +interface and register the class in `src/app.module.ts`: + +```ts +@Injectable() +export class VaultSecretProvider implements SecretProvider { + readonly name = 'vault'; + + constructor(private readonly config: ConfigService) {} + + async getSecret(key: string): Promise { + // Fetch `key` from your secrets manager here. + } +} + +// app.module.ts +SecretsModule.forRoot({ provider: VaultSecretProvider }), +``` + +Then start the app with `SECRETS_PROVIDER=custom`. The secret is resolved +once at boot (`src/auth/jwt-secret.module.ts`) and is used both to sign +tokens (`JwtModule`) and to verify them (`JwtStrategy`). If the secret is +missing, or fails the strength rules above, the app doesn't start. Rotating +the secret requires a restart, and a restart invalidates tokens already +issued. + ## Environment variables @@ -19,6 +94,7 @@ run with a missing or malformed value. See | `OTEL_SERVICE_NAME` | string | optional | `stellar-tickets-backend` \* | yes | Service name attached to exported spans; defaults to stellar-tickets-backend. | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | string | optional | `http://localhost:4318/v1/traces` \* | yes | OTLP HTTP trace collector URL. Defaults to http://localhost:4318/v1/traces. | `PORT` | integer | **required** | `3000` | yes | TCP port the HTTP server binds. Match it to the `port` in docker-compose.yml when running the API in a container. +| `API_PREFIX` | — | optional | — | yes | Optional path every route is mounted under (e.g. `api/v1`), for hosting behind a reverse-proxy path. Health routes stay at the root. | `MAX_ACTIVE_RESALE_LISTINGS_PER_USER` | integer | optional | `5` | yes | Soft cap on how many `ACTIVE` resale listings one seller may hold at once. Listing beyond it is rejected, not queued. See docs/RESALE_EXPIRY.md. | `DATABASE_URL` | string | **required** | — | yes | PostgreSQL connection string for the Prisma client. This is the single system of record; see docs/DATABASE.md. | `RATE_LIMIT_STORE` | `memory \| redis` | optional | `memory` | yes | Where rate-limit counters live. `memory` (default) is per-process; `redis` shares them across instances. See docs/RATE_LIMITING.md. @@ -29,12 +105,14 @@ run with a missing or malformed value. See | `WEBHOOK_QUEUE_BACKOFF_MS` | integer | optional | `5000` \* | yes | Delay before the first webhook retry, doubling on each further retry. Default 5000. | `SCHEDULER_ENABLED` | `true \| false` | optional | `true` | yes | Set to 'false' to switch off every cron job registered through SchedulerModule. On unless 'false'. See docs/SCHEDULER.md. | `REDIS_URL` | string | conditional | — | yes (when required) | Redis connection URL (e.g. redis://localhost:6379). Required when RATE_LIMIT_STORE=redis, CACHE_DRIVER=redis or WEBHOOK_QUEUE_ENABLED=true. -| `JWT_SECRET` | string (min 32 chars) | **required** | — | yes | Secret used to sign and verify JWT access tokens. Must be at least 32 characters. Rotating it invalidates every issued token. See docs/AUTHENTICATION.md. +| `SECRETS_PROVIDER` | — | optional | `env` | yes | Where secrets such as JWT_SECRET are read from: `env` (default), `file` (`_FILE` paths) or `custom` (a provider passed to `SecretsModule.forRoot`). See docs/CONFIGURATION.md. +| `JWT_SECRET` | string | conditional | — | yes (when required) | Required when SECRETS_PROVIDER is `env` (the default). +| `JWT_SECRET_FILE` | string | conditional | — | yes (when required) | Path of the file holding JWT_SECRET; required when SECRETS_PROVIDER=file. | `CORS_ORIGINS` | string | **required** | `http://localhost:3001` | yes | Comma-separated list of allowed CORS origins (e.g. "https://app.example.com,https://staging.example.com"). Wildcard (*) is NOT allowed in production. Used as the CORS allow-list and to build links in outbound email/webhooks. | `JSON_BODY_LIMIT` | string | optional | `100kb` | yes | Maximum accepted JSON request body, as an Express size string such as 100kb or 1mb. Larger payloads are rejected with 413. Default 100kb. | `CSP_DIRECTIVES` | string | optional | `{"defaultSrc":["'self'"],"scriptSrc":["'self'"]}` \* | yes | Helmet Content-Security-Policy directives as a JSON object string, e.g. {"defaultSrc":["'self'"]}. Leave unset to keep helmet's defaults. | `SOROBAN_RPC_URL` | string | **required** | — | yes | Soroban RPC endpoint the StellarService submits contract calls through. -| `STELLAR_NETWORK` | `testnet \| futurenet \| mainnet` | **required** | `testnet` | yes | Which Stellar network every contract call targets. Must match the network the `ticketing` contract is deployed on and the one user wallets are set to, or every submit will fail. +| `STELLAR_NETWORK` | — | **required** | `testnet` | yes | Which Stellar network every contract call targets. Must match the network the `ticketing` contract is deployed on and the one user wallets are set | `TICKETING_CONTRACT_ID` | string | **required** | — | yes | Deployed `ticketing` contract's C... address. | `PLATFORM_SIGNER_SECRET` | string | **required** | — | yes | Platform signer used for contract calls submitted on behalf of the backend itself (e.g. relaying an organizer's already-authorized op). | `OFFLINE_SIGNING_KEY_ID` | string | **required** | `2026-01` | yes | Key id of the Ed25519 key currently used to sign offline verification tokens. Must have a matching entry in OFFLINE_SIGNING_PUBLIC_KEYS. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 7fd34ae..77a2bda 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -33,7 +33,7 @@ Production configuration is driven by environment variables. **Never store produ | `PORT` | Yes | HTTP listening port | `3000` | | `APP_URL` | Yes | Allowed CORS origin URL | `https://app.example.com` | | `DATABASE_URL` | Yes | PostgreSQL connection string | `postgresql://user:pass@db-host:5432/stellartickets?sslmode=require` | -| `JWT_SECRET` | Yes | Secret key for signing auth tokens | Minimum 32-character high-entropy secret | +| `JWT_SECRET` | Yes | Secret key for signing auth tokens | Minimum 32-character high-entropy secret; can instead be loaded via `SECRETS_PROVIDER=file` or a custom provider (see "Secret providers" in `docs/CONFIGURATION.md`) | | `STELLAR_NETWORK` | Yes | Target Stellar network | `mainnet` (or `testnet` for staging) | | `SOROBAN_RPC_URL` | Yes | Production Soroban RPC node URL | `https://mainnet.soroban.rpc.endpoint` | | `TICKETING_CONTRACT_ID` | Yes | Deployed Stellar ticketing contract address | `C...` (Stellar C-address) | diff --git a/src/app.module.ts b/src/app.module.ts index e0eb02c..d14cf45 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,6 +5,7 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import { validate } from './config/env.validation'; import { FeatureFlagsModule } from './config/feature-flags.module'; +import { SecretsModule } from './config/secrets/secrets.module'; import { PrismaModule } from './prisma/prisma.module'; import { AuthModule } from './auth/auth.module'; import { UsersModule } from './users/users.module'; @@ -34,6 +35,7 @@ import { RequestTimeoutInterceptor } from './common/interceptors/request-timeout CacheModule, WebhooksModule, FeatureFlagsModule, + SecretsModule.forRoot(), PrismaModule, StellarModule, AuthModule, diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index eea9dfa..709a0ab 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,19 +1,20 @@ import { Module } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; +import { JWT_SIGNING_SECRET, JwtSecretModule } from './jwt-secret.module'; import { JwtStrategy } from './strategies/jwt.strategy'; @Module({ imports: [ PassportModule, + JwtSecretModule, JwtModule.registerAsync({ - imports: [ConfigModule], - inject: [ConfigService], - useFactory: (config: ConfigService) => ({ - secret: config.getOrThrow('JWT_SECRET'), + imports: [JwtSecretModule], + inject: [JWT_SIGNING_SECRET], + useFactory: (secret: string) => ({ + secret, signOptions: { expiresIn: '1h' }, }), }), diff --git a/src/auth/jwt-secret.module.spec.ts b/src/auth/jwt-secret.module.spec.ts new file mode 100644 index 0000000..a7de978 --- /dev/null +++ b/src/auth/jwt-secret.module.spec.ts @@ -0,0 +1,92 @@ +import { Global, Injectable, Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { Test } from '@nestjs/testing'; +import { SecretProvider } from '../config/secrets/secret-provider'; +import { SecretsModule } from '../config/secrets/secrets.module'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuthModule } from './auth.module'; +import { JWT_SIGNING_SECRET, resolveJwtSecret } from './jwt-secret.module'; +import { JwtStrategy } from './strategies/jwt.strategy'; + +function providerReturning(secret: string | undefined): SecretProvider { + return { name: 'stub', getSecret: () => Promise.resolve(secret) }; +} + +describe('resolveJwtSecret', () => { + const strongSecret = 'k3Jq9vX2pL7mN4bR8tY1wZ6cF0hG5dSa'; + + it('returns the secret from the active provider', async () => { + await expect( + resolveJwtSecret(providerReturning(strongSecret), 'production'), + ).resolves.toBe(strongSecret); + }); + + it('fails when the provider has no JWT_SECRET', async () => { + await expect( + resolveJwtSecret(providerReturning(undefined), 'development'), + ).rejects.toThrow(/not found via the "stub" secret provider/); + }); + + it('applies the production strength rules to provider secrets', async () => { + await expect( + resolveJwtSecret( + providerReturning('changeme-changeme-changeme-changeme'), + 'production', + ), + ).rejects.toThrow(/placeholder/); + }); + + it('enforces the minimum length in every environment', async () => { + await expect( + resolveJwtSecret(providerReturning('short'), 'development'), + ).rejects.toThrow(/at least 32 characters/); + }); +}); + +@Injectable() +class VaultStubProvider implements SecretProvider { + readonly name = 'vault-stub'; + + getSecret(key: string): Promise { + return Promise.resolve( + key === 'JWT_SECRET' ? 'v4ULt-9xQ2mZ7pK3rT8wB1nC6yH0dF5j' : undefined, + ); + } +} + +@Global() +@Module({ + providers: [{ provide: PrismaService, useValue: {} }], + exports: [PrismaService], +}) +class PrismaStubModule {} + +describe('AuthModule secret wiring', () => { + it('signs and verifies tokens with the secret from the active provider', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + ignoreEnvFile: true, + ignoreEnvVars: true, + load: [ + () => ({ NODE_ENV: 'production', SECRETS_PROVIDER: 'custom' }), + ], + }), + SecretsModule.forRoot({ provider: VaultStubProvider }), + PrismaStubModule, + AuthModule, + ], + }).compile(); + + const secret = 'v4ULt-9xQ2mZ7pK3rT8wB1nC6yH0dF5j'; + expect(moduleRef.get(JWT_SIGNING_SECRET)).toBe(secret); + + const token = moduleRef.get(JwtService).sign({ sub: 'user-1' }); + expect(new JwtService().verify(token, { secret })).toMatchObject({ + sub: 'user-1', + }); + expect(moduleRef.get(JwtStrategy)).toBeDefined(); + }); +}); diff --git a/src/auth/jwt-secret.module.ts b/src/auth/jwt-secret.module.ts new file mode 100644 index 0000000..3b6029e --- /dev/null +++ b/src/auth/jwt-secret.module.ts @@ -0,0 +1,42 @@ +import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { assertStrongJwtSecret } from '../config/jwt-secret'; +import { + SECRET_PROVIDER, + SecretProvider, +} from '../config/secrets/secret-provider'; + +/** Injection token for the resolved JWT signing secret. */ +export const JWT_SIGNING_SECRET = Symbol('JWT_SIGNING_SECRET'); + +/** + * Resolves `JWT_SECRET` once at boot through the active secret provider and + * applies the same strength rules as env validation, so a weak secret fails + * startup whichever provider supplied it. + */ +export async function resolveJwtSecret( + provider: SecretProvider, + nodeEnv: string, +): Promise { + const secret = await provider.getSecret('JWT_SECRET'); + if (!secret) { + throw new Error( + `JWT_SECRET was not found via the "${provider.name}" secret provider`, + ); + } + assertStrongJwtSecret(secret, nodeEnv); + return secret; +} + +@Module({ + providers: [ + { + provide: JWT_SIGNING_SECRET, + inject: [SECRET_PROVIDER, ConfigService], + useFactory: (provider: SecretProvider, config: ConfigService) => + resolveJwtSecret(provider, config.getOrThrow('NODE_ENV')), + }, + ], + exports: [JWT_SIGNING_SECRET], +}) +export class JwtSecretModule {} diff --git a/src/auth/strategies/jwt.strategy.spec.ts b/src/auth/strategies/jwt.strategy.spec.ts index e20ed00..e2fcb7a 100644 --- a/src/auth/strategies/jwt.strategy.spec.ts +++ b/src/auth/strategies/jwt.strategy.spec.ts @@ -1,10 +1,8 @@ -import { ConfigService } from '@nestjs/config'; import { JwtStrategy } from './jwt.strategy'; describe('JwtStrategy', () => { it('maps a decoded payload to the shape guards and controllers expect', () => { - const config = { getOrThrow: jest.fn().mockReturnValue('x'.repeat(32)) }; - const strategy = new JwtStrategy(config as unknown as ConfigService); + const strategy = new JwtStrategy('x'.repeat(32)); const result = strategy.validate({ sub: 'user-1', @@ -19,11 +17,7 @@ describe('JwtStrategy', () => { }); }); - it('reads JWT_SECRET from config at construction time', () => { - const config = { getOrThrow: jest.fn().mockReturnValue('x'.repeat(32)) }; - const strategy = new JwtStrategy(config as unknown as ConfigService); - expect(strategy).toBeDefined(); - - expect(config.getOrThrow).toHaveBeenCalledWith('JWT_SECRET'); + it('is constructed with the secret resolved by JwtSecretModule', () => { + expect(new JwtStrategy('x'.repeat(32))).toBeDefined(); }); }); diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts index bbce4b1..02cc419 100644 --- a/src/auth/strategies/jwt.strategy.ts +++ b/src/auth/strategies/jwt.strategy.ts @@ -1,7 +1,7 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { Inject, Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; +import { JWT_SIGNING_SECRET } from '../jwt-secret.module'; export interface JwtPayload { sub: string; @@ -11,11 +11,11 @@ export interface JwtPayload { @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { - constructor(config: ConfigService) { + constructor(@Inject(JWT_SIGNING_SECRET) secret: string) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: config.getOrThrow('JWT_SECRET'), + secretOrKey: secret, }); } diff --git a/src/config/api-prefix.spec.ts b/src/config/api-prefix.spec.ts new file mode 100644 index 0000000..059551e --- /dev/null +++ b/src/config/api-prefix.spec.ts @@ -0,0 +1,80 @@ +import { Controller, Get, INestApplication, Post } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { AppController } from '../app.controller'; +import { AppService } from '../app.service'; +import { applyApiPrefix, normalizeApiPrefix } from './api-prefix'; + +@Controller('events') +class EventsStubController { + @Get() + list() { + return []; + } + + @Post() + create() { + return { id: 'evt-1' }; + } +} + +async function createApp(prefix?: string): Promise> { + const moduleRef = await Test.createTestingModule({ + controllers: [AppController, EventsStubController], + providers: [AppService], + }).compile(); + const app = moduleRef.createNestApplication>(); + applyApiPrefix(app, prefix); + await app.init(); + return app; +} + +describe('normalizeApiPrefix', () => { + it.each([ + ['api', 'api'], + ['/api/v1/', 'api/v1'], + [' /v2 ', 'v2'], + ['', undefined], + ['/', undefined], + [undefined, undefined], + ])('normalizes %p to %p', (raw, expected) => { + expect(normalizeApiPrefix(raw)).toBe(expected); + }); +}); + +describe('applyApiPrefix', () => { + let app: INestApplication; + + afterEach(async () => { + await app.close(); + }); + + it('mounts routes under the prefix', async () => { + app = await createApp('/api/v1/'); + const server = app.getHttpServer(); + + await request(server).get('/api/v1/events').expect(200); + await request(server).post('/api/v1/events').expect(201); + await request(server).get('/events').expect(404); + }); + + it('keeps the health route at the root', async () => { + app = await createApp('api'); + const server = app.getHttpServer(); + + await request(server) + .get('/health') + .expect(200) + .expect({ status: 'ok', service: 'stellar-tickets-backend' }); + await request(server).get('/api/health').expect(404); + }); + + it('leaves routes unprefixed when API_PREFIX is unset', async () => { + app = await createApp(undefined); + const server = app.getHttpServer(); + + await request(server).get('/events').expect(200); + await request(server).get('/health').expect(200); + }); +}); diff --git a/src/config/api-prefix.ts b/src/config/api-prefix.ts new file mode 100644 index 0000000..559d1fd --- /dev/null +++ b/src/config/api-prefix.ts @@ -0,0 +1,34 @@ +import { INestApplication, RequestMethod } from '@nestjs/common'; + +/** + * Routes that stay at the root when `API_PREFIX` is set, so load balancer and + * orchestrator probes keep working regardless of the public path. + */ +export const API_PREFIX_EXCLUDED_ROUTES = [ + { path: 'health', method: RequestMethod.ALL }, +]; + +/** + * Matches `api`, `/api/v1/`, `v1.0`, … — URL-safe path segments only, and + * never a `.` or `..` segment. + */ +export const API_PREFIX_PATTERN = + /^(\/?(?!\.\.?(?:\/|$))[A-Za-z0-9._~-]+(\/(?!\.\.?(?:\/|$))[A-Za-z0-9._~-]+)*\/?)?$/; + +/** Strips surrounding slashes; returns `undefined` for an empty prefix. */ +export function normalizeApiPrefix(raw?: string): string | undefined { + const prefix = (raw ?? '').trim().replace(/^\/+|\/+$/g, ''); + return prefix.length > 0 ? prefix : undefined; +} + +/** + * Mounts every route under `API_PREFIX` (e.g. `API_PREFIX=api/v1` serves + * `POST /auth/login` as `POST /api/v1/auth/login`), except the health routes. + * Must run before `app.init()` / `app.listen()`. No-op when unset. + */ +export function applyApiPrefix(app: INestApplication, raw?: string): void { + const prefix = normalizeApiPrefix(raw); + if (prefix) { + app.setGlobalPrefix(prefix, { exclude: API_PREFIX_EXCLUDED_ROUTES }); + } +} diff --git a/src/config/env.validation.spec.ts b/src/config/env.validation.spec.ts index 503915f..f0b0ba0 100644 --- a/src/config/env.validation.spec.ts +++ b/src/config/env.validation.spec.ts @@ -168,6 +168,148 @@ describe('env.validate', () => { expect(() => validate(config)).toThrow(); }); + describe('API_PREFIX (#250)', () => { + it.each(['api', '/api/v1/', 'v1.0', ''])('accepts %p', (API_PREFIX) => { + expect(() => validate(validConfig({ API_PREFIX }))).not.toThrow(); + }); + + it.each(['api v1', 'api//v1', 'api?x=1', '../api', 'api/./v1'])( + 'rejects %p', + (API_PREFIX) => { + expect(() => validate(validConfig({ API_PREFIX }))).toThrow( + /API_PREFIX/, + ); + }, + ); + }); + + describe('JWT_SECRET strength (#251)', () => { + const strongSecret = 'k3Jq9vX2pL7mN4bR8tY1wZ6cF0hG5dSa'; + + it('accepts a random secret in production', () => { + expect(() => + validate( + validConfig({ NODE_ENV: 'production', JWT_SECRET: strongSecret }), + ), + ).not.toThrow(); + }); + + it('rejects a short secret in production', () => { + expect(() => + validate(validConfig({ NODE_ENV: 'production', JWT_SECRET: 'short' })), + ).toThrow(); + }); + + it.each([ + 'changeme-changeme-changeme-changeme', + 'your-jwt-secret-goes-here-1234567890', + 'CHANGE_ME_TO_A_RANDOM_32_CHARACTER_VALUE', + 'super-secret-key-for-example-app-2026', + ])('rejects the placeholder %p in production', (JWT_SECRET) => { + expect(() => + validate(validConfig({ NODE_ENV: 'production', JWT_SECRET })), + ).toThrow(/placeholder/); + }); + + it('rejects a low-variety secret in production', () => { + expect(() => + validate( + validConfig({ NODE_ENV: 'production', JWT_SECRET: 'ab'.repeat(20) }), + ), + ).toThrow(/distinct characters/); + }); + + it('allows placeholder-style secrets outside production', () => { + expect(() => + validate( + validConfig({ + NODE_ENV: 'development', + JWT_SECRET: 'changeme-changeme-changeme-changeme', + }), + ), + ).not.toThrow(); + }); + }); + + describe('SOROBAN_RPC_URL / STELLAR_NETWORK pairing (#252)', () => { + it.each([ + ['https://soroban-testnet.stellar.org', 'testnet'], + ['https://rpc-futurenet.stellar.org', 'futurenet'], + ['https://mainnet.sorobanrpc.com', 'mainnet'], + ['http://localhost:8000/soroban/rpc', 'testnet'], + ['https://rpc.internal.example.net', 'mainnet'], + ])('accepts %s on %s', (SOROBAN_RPC_URL, STELLAR_NETWORK) => { + expect(() => + validate(validConfig({ SOROBAN_RPC_URL, STELLAR_NETWORK })), + ).not.toThrow(); + }); + + it.each([ + ['https://soroban-testnet.stellar.org', 'mainnet'], + ['https://soroban-testnet.stellar.org', 'futurenet'], + ['https://rpc-futurenet.stellar.org', 'testnet'], + ['https://mainnet.sorobanrpc.com', 'testnet'], + ])('rejects %s on %s', (SOROBAN_RPC_URL, STELLAR_NETWORK) => { + expect(() => + validate(validConfig({ SOROBAN_RPC_URL, STELLAR_NETWORK })), + ).toThrow(/wrong network passphrase/); + }); + + it('rejects an RPC URL that is not http(s)', () => { + expect(() => + validate(validConfig({ SOROBAN_RPC_URL: 'ftp://rpc.example.com' })), + ).toThrow(/http or https/); + }); + + it('rejects an RPC URL that does not parse', () => { + expect(() => + validate(validConfig({ SOROBAN_RPC_URL: 'not a url' })), + ).toThrow(/not a valid URL/); + }); + }); + + describe('SECRETS_PROVIDER (#253)', () => { + it('defaults to env and requires JWT_SECRET', () => { + const config = validConfig(); + delete (config as Record).JWT_SECRET; + expect(() => validate(config)).toThrow(/JWT_SECRET/); + }); + + it('with file, requires JWT_SECRET_FILE instead of JWT_SECRET', () => { + const config = validConfig({ SECRETS_PROVIDER: 'file' }); + delete (config as Record).JWT_SECRET; + expect(() => validate(config)).toThrow(/JWT_SECRET_FILE/); + expect(() => + validate({ ...config, JWT_SECRET_FILE: '/run/secrets/jwt' }), + ).not.toThrow(); + }); + + it('with file, ignores a leftover empty JWT_SECRET', () => { + expect(() => + validate( + validConfig({ + NODE_ENV: 'production', + SECRETS_PROVIDER: 'file', + JWT_SECRET: '', + JWT_SECRET_FILE: '/run/secrets/jwt', + }), + ), + ).not.toThrow(); + }); + + it('with custom, requires neither', () => { + const config = validConfig({ SECRETS_PROVIDER: 'custom' }); + delete (config as Record).JWT_SECRET; + expect(() => validate(config)).not.toThrow(); + }); + + it('rejects an unknown provider', () => { + expect(() => + validate(validConfig({ SECRETS_PROVIDER: 'vault' })), + ).toThrow(); + }); + }); + describe('CORS_ORIGINS', () => { it('accepts comma-separated origins', () => { expect(() => @@ -190,6 +332,7 @@ describe('env.validate', () => { validate( validConfig({ NODE_ENV: 'production', + JWT_SECRET: 'k3Jq9vX2pL7mN4bR8tY1wZ6cF0hG5dSa', CORS_ORIGINS: '*', }), ), diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index fd935ab..53b6a56 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -4,10 +4,15 @@ import { IsInt, IsOptional, IsString, + Matches, MinLength, ValidateIf, validateSync, } from 'class-validator'; +import { API_PREFIX_PATTERN } from './api-prefix'; +import { getJwtSecretProblems, JWT_SECRET_MIN_LENGTH } from './jwt-secret'; +import { SECRET_PROVIDER_KINDS } from './secrets/secret-provider'; +import { getRpcNetworkProblems, STELLAR_NETWORKS } from './stellar-networks'; class EnvironmentVariables { /// Which NestJS environment the app runs in. Drives logging verbosity and @@ -36,6 +41,14 @@ class EnvironmentVariables { @IsInt() PORT!: number; + /// Optional path every route is mounted under (e.g. `api/v1`), for hosting + /// behind a reverse-proxy path. Health routes stay at the root. + @IsOptional() + @Matches(API_PREFIX_PATTERN, { + message: 'API_PREFIX must be URL path segments such as "api" or "api/v1"', + }) + API_PREFIX?: string; + /// Soft cap on how many `ACTIVE` resale listings one seller may hold at /// once. Listing beyond it is rejected, not queued. See docs/RESALE_EXPIRY.md. @IsInt() @@ -102,12 +115,23 @@ class EnvironmentVariables { @IsString() REDIS_URL?: string; - /// Secret used to sign and verify JWT access tokens. Must be at least 32 - /// characters. Rotating it invalidates every issued token. See - /// docs/AUTHENTICATION.md. + /// Where secrets such as JWT_SECRET are read from: `env` (default), + /// `file` (`_FILE` paths) or `custom` (a provider passed to + /// `SecretsModule.forRoot`). See docs/CONFIGURATION.md. + @IsOptional() + @IsIn(SECRET_PROVIDER_KINDS) + SECRETS_PROVIDER?: string; + + /// Required when SECRETS_PROVIDER is `env` (the default). + @ValidateIf((o: EnvironmentVariables) => secretsProvider(o) === 'env') @IsString() - @MinLength(32) - JWT_SECRET!: string; + @MinLength(JWT_SECRET_MIN_LENGTH) + JWT_SECRET?: string; + + /// Path of the file holding JWT_SECRET; required when SECRETS_PROVIDER=file. + @ValidateIf((o: EnvironmentVariables) => secretsProvider(o) === 'file') + @IsString() + JWT_SECRET_FILE?: string; /// Comma-separated list of allowed CORS origins (e.g. "https://app.example.com,https://staging.example.com"). /// Wildcard (*) is NOT allowed in production. Used as the CORS allow-list @@ -133,8 +157,7 @@ class EnvironmentVariables { /// Which Stellar network every contract call targets. Must match the network /// the `ticketing` contract is deployed on and the one user wallets are set - /// to, or every submit will fail. - @IsIn(['testnet', 'futurenet', 'mainnet']) + @IsIn(STELLAR_NETWORKS) STELLAR_NETWORK!: string; /// Deployed `ticketing` contract's C... address. @@ -180,6 +203,24 @@ class EnvironmentVariables { IDEMPOTENCY_KEY_TTL_MINUTES?: number; } +function secretsProvider(env: EnvironmentVariables): string { + return env.SECRETS_PROVIDER ?? 'env'; +} + +/** Checks that span several variables, run once each field is well-formed. */ +function crossFieldProblems(env: EnvironmentVariables): string[] { + const problems = getRpcNetworkProblems( + env.SOROBAN_RPC_URL, + env.STELLAR_NETWORK, + ); + // Secrets resolved through another provider are checked when the provider + // resolves them (see src/auth/jwt-secret.module.ts). + if (secretsProvider(env) === 'env' && env.JWT_SECRET !== undefined) { + problems.push(...getJwtSecretProblems(env.JWT_SECRET, env.NODE_ENV)); + } + return problems; +} + export function validate(config: Record) { const validated = plainToInstance(EnvironmentVariables, config, { enableImplicitConversion: true, @@ -190,6 +231,13 @@ export function validate(config: Record) { throw new Error(`Invalid environment configuration: ${errors.toString()}`); } + const problems = crossFieldProblems(validated); + if (problems.length > 0) { + throw new Error( + `Invalid environment configuration: ${problems.join('; ')}`, + ); + } + const corsOrigins = validated.CORS_ORIGINS.split(',').map((o) => o.trim()); if (validated.NODE_ENV === 'production' && corsOrigins.includes('*')) { throw new Error('CORS_ORIGINS wildcard (*) is not allowed in production'); diff --git a/src/config/jwt-secret.ts b/src/config/jwt-secret.ts new file mode 100644 index 0000000..facf60a --- /dev/null +++ b/src/config/jwt-secret.ts @@ -0,0 +1,66 @@ +export const JWT_SECRET_MIN_LENGTH = 32; + +/** Minimum number of distinct characters a production secret must contain. */ +const MIN_DISTINCT_CHARS = 10; + +/** + * Fragments of well-known placeholder / sample secrets. Matched against the + * secret lower-cased with non-alphanumerics stripped, so `change-me`, + * `CHANGE_ME` and `changeme` are all caught. + */ +const PLACEHOLDER_FRAGMENTS = [ + 'changeme', + 'replaceme', + 'yoursecret', + 'yourjwtsecret', + 'jwtsecret', + 'secretkey', + 'mysecret', + 'placeholder', + 'example', + 'default', + 'insecure', + 'donotuse', + 'password', +]; + +/** + * Problems with a JWT signing secret. Every environment enforces the minimum + * length; production additionally rejects placeholder values and + * low-variety strings (e.g. `x` repeated 32 times) that pass a length check + * but are trivially guessable. + */ +export function getJwtSecretProblems( + secret: string, + nodeEnv: string, +): string[] { + const problems: string[] = []; + if (secret.length < JWT_SECRET_MIN_LENGTH) { + problems.push( + `JWT_SECRET must be at least ${JWT_SECRET_MIN_LENGTH} characters`, + ); + } + if (nodeEnv !== 'production') return problems; + + const normalized = secret.toLowerCase().replace(/[^a-z0-9]/g, ''); + const fragment = PLACEHOLDER_FRAGMENTS.find((f) => normalized.includes(f)); + if (fragment) { + problems.push( + `JWT_SECRET looks like a placeholder (contains "${fragment}"); generate a random secret`, + ); + } + if (new Set(secret).size < MIN_DISTINCT_CHARS) { + problems.push( + `JWT_SECRET must contain at least ${MIN_DISTINCT_CHARS} distinct characters in production`, + ); + } + return problems; +} + +/** Throws if `secret` fails {@link getJwtSecretProblems}. */ +export function assertStrongJwtSecret(secret: string, nodeEnv: string): void { + const problems = getJwtSecretProblems(secret, nodeEnv); + if (problems.length > 0) { + throw new Error(`Weak JWT secret: ${problems.join('; ')}`); + } +} diff --git a/src/config/secrets/env-secret.provider.ts b/src/config/secrets/env-secret.provider.ts new file mode 100644 index 0000000..bb0f187 --- /dev/null +++ b/src/config/secrets/env-secret.provider.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { SecretProvider } from './secret-provider'; + +/** Default provider: reads the secret from the validated environment. */ +@Injectable() +export class EnvSecretProvider implements SecretProvider { + readonly name = 'env'; + + constructor(private readonly config: ConfigService) {} + + getSecret(key: string): Promise { + return Promise.resolve(this.config.get(key)); + } +} diff --git a/src/config/secrets/file-secret.provider.ts b/src/config/secrets/file-secret.provider.ts new file mode 100644 index 0000000..c551245 --- /dev/null +++ b/src/config/secrets/file-secret.provider.ts @@ -0,0 +1,30 @@ +import { readFile } from 'node:fs/promises'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { SecretProvider } from './secret-provider'; + +/** + * Reads `` from the file named by `_FILE` (e.g. `JWT_SECRET_FILE`). + * This is how Docker / Kubernetes secrets and secrets-manager sidecars (Vault + * Agent, the AWS / GCP Secrets Store CSI drivers) expose values, so the + * secret never has to live in the process environment. Surrounding + * whitespace, including the trailing newline most tools write, is trimmed. + */ +@Injectable() +export class FileSecretProvider implements SecretProvider { + readonly name = 'file'; + + constructor(private readonly config: ConfigService) {} + + async getSecret(key: string): Promise { + const path = this.config.get(`${key}_FILE`); + if (!path) return undefined; + try { + return (await readFile(path, 'utf8')).trim(); + } catch (err) { + throw new Error( + `Could not read ${key} from ${key}_FILE (${path}): ${(err as Error).message}`, + ); + } + } +} diff --git a/src/config/secrets/secret-provider.ts b/src/config/secrets/secret-provider.ts new file mode 100644 index 0000000..b684677 --- /dev/null +++ b/src/config/secrets/secret-provider.ts @@ -0,0 +1,18 @@ +/** Injection token for the active {@link SecretProvider}. */ +export const SECRET_PROVIDER = Symbol('SECRET_PROVIDER'); + +export const SECRET_PROVIDER_KINDS = ['env', 'file', 'custom'] as const; +export type SecretProviderKind = (typeof SECRET_PROVIDER_KINDS)[number]; + +/** + * Source of sensitive values such as `JWT_SECRET`. The default reads plain + * environment variables; swap it for a secrets manager (Vault, AWS Secrets + * Manager, GCP Secret Manager, …) by implementing this interface and passing + * it to `SecretsModule.forRoot({ provider })`. See docs/CONFIGURATION.md. + */ +export interface SecretProvider { + /** Short name used in error messages, e.g. `env`. */ + readonly name: string; + /** Returns the secret stored under `key`, or `undefined` if absent. */ + getSecret(key: string): Promise; +} diff --git a/src/config/secrets/secrets.module.spec.ts b/src/config/secrets/secrets.module.spec.ts new file mode 100644 index 0000000..744b6f3 --- /dev/null +++ b/src/config/secrets/secrets.module.spec.ts @@ -0,0 +1,109 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Injectable } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { Test } from '@nestjs/testing'; +import { SECRET_PROVIDER, SecretProvider } from './secret-provider'; +import { SecretsModule, SecretsModuleOptions } from './secrets.module'; + +@Injectable() +class InMemorySecretProvider implements SecretProvider { + readonly name = 'in-memory'; + + getSecret(key: string): Promise { + return Promise.resolve(key === 'JWT_SECRET' ? 'from-vault' : undefined); + } +} + +async function resolveProvider( + env: Record, + options?: SecretsModuleOptions, +): Promise { + const moduleRef = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + ignoreEnvFile: true, + ignoreEnvVars: true, + load: [() => env], + }), + SecretsModule.forRoot(options), + ], + }).compile(); + return moduleRef.get(SECRET_PROVIDER); +} + +describe('SecretsModule', () => { + it('defaults to the env provider', async () => { + const provider = await resolveProvider({ JWT_SECRET: 'from-env' }); + + expect(provider.name).toBe('env'); + await expect(provider.getSecret('JWT_SECRET')).resolves.toBe('from-env'); + await expect(provider.getSecret('MISSING')).resolves.toBeUndefined(); + }); + + describe('file provider', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'secrets-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('reads _FILE and trims the trailing newline', async () => { + const path = join(dir, 'jwt'); + writeFileSync(path, 'from-file\n'); + const provider = await resolveProvider({ + SECRETS_PROVIDER: 'file', + JWT_SECRET: 'ignored-env-value', + JWT_SECRET_FILE: path, + }); + + expect(provider.name).toBe('file'); + await expect(provider.getSecret('JWT_SECRET')).resolves.toBe('from-file'); + }); + + it('returns undefined when _FILE is unset', async () => { + const provider = await resolveProvider({ SECRETS_PROVIDER: 'file' }); + await expect(provider.getSecret('JWT_SECRET')).resolves.toBeUndefined(); + }); + + it('fails clearly when the file cannot be read', async () => { + const provider = await resolveProvider({ + SECRETS_PROVIDER: 'file', + JWT_SECRET_FILE: join(dir, 'missing'), + }); + await expect(provider.getSecret('JWT_SECRET')).rejects.toThrow( + /Could not read JWT_SECRET from JWT_SECRET_FILE/, + ); + }); + }); + + it('uses a custom provider when SECRETS_PROVIDER=custom', async () => { + const provider = await resolveProvider( + { SECRETS_PROVIDER: 'custom' }, + { provider: InMemorySecretProvider }, + ); + + expect(provider.name).toBe('in-memory'); + await expect(provider.getSecret('JWT_SECRET')).resolves.toBe('from-vault'); + }); + + it('ignores a registered custom provider unless selected', async () => { + const provider = await resolveProvider( + { JWT_SECRET: 'from-env' }, + { provider: InMemorySecretProvider }, + ); + expect(provider.name).toBe('env'); + }); + + it('fails boot when SECRETS_PROVIDER=custom has no provider', async () => { + await expect( + resolveProvider({ SECRETS_PROVIDER: 'custom' }), + ).rejects.toThrow(/requires SecretsModule.forRoot/); + }); +}); diff --git a/src/config/secrets/secrets.module.ts b/src/config/secrets/secrets.module.ts new file mode 100644 index 0000000..0c1539d --- /dev/null +++ b/src/config/secrets/secrets.module.ts @@ -0,0 +1,71 @@ +import { DynamicModule, Global, Module, Provider, Type } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { EnvSecretProvider } from './env-secret.provider'; +import { FileSecretProvider } from './file-secret.provider'; +import { + SECRET_PROVIDER, + SecretProvider, + SecretProviderKind, +} from './secret-provider'; + +export interface SecretsModuleOptions { + /** + * Custom provider class, e.g. a Vault or AWS Secrets Manager client. It is + * instantiated by Nest, so it may inject `ConfigService` or anything else + * that is globally available. Requires `SECRETS_PROVIDER=custom`. + */ + provider?: Type; +} + +/** + * Exposes the active {@link SecretProvider} under {@link SECRET_PROVIDER}, + * selected by `SECRETS_PROVIDER` (`env` by default, or `file`). Pass + * `provider` to plug in a secrets manager with `SECRETS_PROVIDER=custom`. + */ +@Global() +@Module({}) +export class SecretsModule { + static forRoot(options: SecretsModuleOptions = {}): DynamicModule { + const custom = options.provider; + const providers: Provider[] = [EnvSecretProvider, FileSecretProvider]; + const inject: Type[] = [ + ConfigService, + EnvSecretProvider, + FileSecretProvider, + ]; + if (custom) { + providers.push(custom); + inject.push(custom); + } + + providers.push({ + provide: SECRET_PROVIDER, + inject, + useFactory: ( + config: ConfigService, + env: EnvSecretProvider, + file: FileSecretProvider, + customInstance?: SecretProvider, + ): SecretProvider => { + const kind = + config.get('SECRETS_PROVIDER') ?? 'env'; + if (kind === 'file') return file; + if (kind === 'custom') { + if (!customInstance) { + throw new Error( + 'SECRETS_PROVIDER=custom requires SecretsModule.forRoot({ provider })', + ); + } + return customInstance; + } + return env; + }, + }); + + return { + module: SecretsModule, + providers, + exports: [SECRET_PROVIDER], + }; + } +} diff --git a/src/config/stellar-networks.spec.ts b/src/config/stellar-networks.spec.ts new file mode 100644 index 0000000..f5d6de0 --- /dev/null +++ b/src/config/stellar-networks.spec.ts @@ -0,0 +1,49 @@ +import { + getRpcNetworkProblems, + inferNetworkFromRpcUrl, +} from './stellar-networks'; + +describe('inferNetworkFromRpcUrl', () => { + it.each([ + ['https://soroban-testnet.stellar.org', 'testnet'], + ['https://soroban-testnet.stellar.org:443/', 'testnet'], + ['https://rpc-futurenet.stellar.org', 'futurenet'], + ['https://mainnet.sorobanrpc.com', 'mainnet'], + ['https://soroban-rpc.pubnet.example.io', 'mainnet'], + ['https://rpc.ankr.com/stellar_testnet_soroban', 'testnet'], + ])('infers %s as %s', (url, network) => { + expect(inferNetworkFromRpcUrl(url)).toBe(network); + }); + + it.each([ + 'http://localhost:8000/soroban/rpc', + 'https://rpc.internal.example.net', + 'https://testnet-to-mainnet-bridge.example.com', + 'not a url', + ])('returns undefined for %s', (url) => { + expect(inferNetworkFromRpcUrl(url)).toBeUndefined(); + }); + + it('does not match network names embedded in longer words', () => { + expect(inferNetworkFromRpcUrl('https://mytestnetwork.example.com')).toBe( + undefined, + ); + }); +}); + +describe('getRpcNetworkProblems', () => { + it('reports nothing for a matching pair', () => { + expect( + getRpcNetworkProblems('https://soroban-testnet.stellar.org', 'testnet'), + ).toEqual([]); + }); + + it('reports the mismatch for a bad pair', () => { + const [problem] = getRpcNetworkProblems( + 'https://soroban-testnet.stellar.org', + 'mainnet', + ); + expect(problem).toContain('serves testnet'); + expect(problem).toContain('"mainnet"'); + }); +}); diff --git a/src/config/stellar-networks.ts b/src/config/stellar-networks.ts new file mode 100644 index 0000000..c2b3643 --- /dev/null +++ b/src/config/stellar-networks.ts @@ -0,0 +1,79 @@ +import { Networks } from '@stellar/stellar-sdk'; + +export const STELLAR_NETWORKS = ['testnet', 'futurenet', 'mainnet'] as const; +export type StellarNetwork = (typeof STELLAR_NETWORKS)[number]; + +/** + * Passphrase every transaction is signed against for each supported + * `STELLAR_NETWORK`. The backend never takes a passphrase from config — it is + * always derived from the network name, so the RPC endpoint is the only other + * half of the pairing that can drift. + */ +export const NETWORK_PASSPHRASES: Record = { + testnet: Networks.TESTNET, + futurenet: Networks.FUTURENET, + mainnet: Networks.PUBLIC, +}; + +/** Host/path tokens that identify which network an RPC endpoint serves. */ +const NETWORK_URL_TOKENS: Record = { + testnet: 'testnet', + futurenet: 'futurenet', + mainnet: 'mainnet', + pubnet: 'mainnet', +}; + +/** + * Infers the network a Soroban RPC URL serves from well-known naming, e.g. + * `soroban-testnet.stellar.org`, `rpc-futurenet.stellar.org` or + * `mainnet.sorobanrpc.com`. Returns `undefined` when the URL names no network + * (a self-hosted or local node) or names more than one, so only an + * unambiguous mismatch is ever reported. + */ +export function inferNetworkFromRpcUrl( + rpcUrl: string, +): StellarNetwork | undefined { + let url: URL; + try { + url = new URL(rpcUrl); + } catch { + return undefined; + } + const tokens = `${url.hostname}${url.pathname}` + .toLowerCase() + .split(/[^a-z0-9]+/); + const found = new Set(); + for (const token of tokens) { + const network = NETWORK_URL_TOKENS[token]; + if (network) found.add(network); + } + return found.size === 1 ? [...found][0] : undefined; +} + +/** + * Problems with the `SOROBAN_RPC_URL` / `STELLAR_NETWORK` pairing. A mismatch + * means transactions would be built and signed with one network's passphrase + * but simulated and submitted against another, so it must fail boot. + */ +export function getRpcNetworkProblems( + rpcUrl: string, + network: string, +): string[] { + let url: URL; + try { + url = new URL(rpcUrl); + } catch { + return [`SOROBAN_RPC_URL "${rpcUrl}" is not a valid URL`]; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return [`SOROBAN_RPC_URL must use http or https, got "${url.protocol}"`]; + } + const inferred = inferNetworkFromRpcUrl(rpcUrl); + if (inferred && inferred !== network) { + return [ + `SOROBAN_RPC_URL "${rpcUrl}" serves ${inferred} but STELLAR_NETWORK is ` + + `"${network}"; transactions would be signed with the wrong network passphrase`, + ]; + } + return []; +} diff --git a/src/main.ts b/src/main.ts index c8b3d73..323bf35 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,6 +12,7 @@ async function bootstrap() { { default: helmet }, { DocumentBuilder, SwaggerModule }, { GlobalExceptionFilter }, + { applyApiPrefix }, ] = await Promise.all([ import('@nestjs/core'), import('./app.module.js'), @@ -20,11 +21,13 @@ async function bootstrap() { import('helmet'), import('@nestjs/swagger'), import('./common/filters/global-exception.filter.js'), + import('./config/api-prefix.js'), ]); const app = await NestFactory.create(AppModule); if (tracing) app.enableShutdownHooks(); const config = app.get(ConfigService); + applyApiPrefix(app, config.get('API_PREFIX')); const cspDirectives = config.get('CSP_DIRECTIVES'); const helmetOptions: Record = {}; if (cspDirectives) { diff --git a/src/stellar/stellar.service.ts b/src/stellar/stellar.service.ts index 401b5e8..5f64c9e 100644 --- a/src/stellar/stellar.service.ts +++ b/src/stellar/stellar.service.ts @@ -9,16 +9,13 @@ import { TransactionBuilder, rpc, BASE_FEE, - Networks, xdr, } from '@stellar/stellar-sdk'; import { CircuitBreaker } from './circuit-breaker'; - -const NETWORK_PASSPHRASES: Record = { - testnet: Networks.TESTNET, - futurenet: Networks.FUTURENET, - mainnet: Networks.PUBLIC, -}; +import { + NETWORK_PASSPHRASES, + StellarNetwork, +} from '../config/stellar-networks'; export interface OnChainTicket { eventId: bigint; @@ -67,7 +64,7 @@ export class StellarService { constructor(private readonly config: ConfigService) { const rpcUrl = this.config.getOrThrow('SOROBAN_RPC_URL'); - const network = this.config.getOrThrow('STELLAR_NETWORK'); + const network = this.config.getOrThrow('STELLAR_NETWORK'); this.server = new rpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith('http://'), });