From 5c9a3e44d16df14451e7aafa2a906e8d415a0ce3 Mon Sep 17 00:00:00 2001 From: UFObject247 <105864783+UFObject247@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:17:11 +0100 Subject: [PATCH] feat(config): API prefix, JWT secret hardening, RPC/network check, secret providers - API_PREFIX mounts every route under a path prefix via setGlobalPrefix, with GET /health excluded so probes keep working (#250). - JWT_SECRET must be 32+ characters everywhere; in production it must also not be a known placeholder and must have 10+ distinct characters (#251). - Boot fails when SOROBAN_RPC_URL names a different network than STELLAR_NETWORK, since the passphrase is derived from STELLAR_NETWORK; the passphrase table now lives in src/config/stellar-networks.ts (#252). - JWT_SECRET is resolved at boot through a pluggable SecretProvider: env (default), file (_FILE), or a custom provider passed to SecretsModule.forRoot. Signing and verification share the resolved secret, and the strength rules apply to it whatever the source (#253). Closes #250 Closes #251 Closes #252 Closes #253 --- .env.example | 16 ++- docs/API.md | 4 + docs/CONFIGURATION.md | 75 +++++++++++ docs/DEPLOYMENT.md | 4 +- src/app.module.ts | 2 + src/auth/auth.module.ts | 11 +- src/auth/jwt-secret.module.spec.ts | 92 +++++++++++++ src/auth/jwt-secret.module.ts | 42 ++++++ src/auth/strategies/jwt.strategy.spec.ts | 12 +- src/auth/strategies/jwt.strategy.ts | 8 +- src/config/api-prefix.spec.ts | 80 ++++++++++++ src/config/api-prefix.ts | 34 +++++ src/config/env.validation.spec.ts | 142 +++++++++++++++++++++ src/config/env.validation.ts | 59 ++++++++- src/config/jwt-secret.ts | 66 ++++++++++ src/config/secrets/env-secret.provider.ts | 15 +++ src/config/secrets/file-secret.provider.ts | 30 +++++ src/config/secrets/secret-provider.ts | 18 +++ src/config/secrets/secrets.module.spec.ts | 109 ++++++++++++++++ src/config/secrets/secrets.module.ts | 71 +++++++++++ src/config/stellar-networks.spec.ts | 49 +++++++ src/config/stellar-networks.ts | 79 ++++++++++++ src/main.ts | 2 + src/stellar/stellar.service.ts | 15 +-- 24 files changed, 1002 insertions(+), 33 deletions(-) create mode 100644 src/auth/jwt-secret.module.spec.ts create mode 100644 src/auth/jwt-secret.module.ts create mode 100644 src/config/api-prefix.spec.ts create mode 100644 src/config/api-prefix.ts create mode 100644 src/config/jwt-secret.ts create mode 100644 src/config/secrets/env-secret.provider.ts create mode 100644 src/config/secrets/file-secret.provider.ts create mode 100644 src/config/secrets/secret-provider.ts create mode 100644 src/config/secrets/secrets.module.spec.ts create mode 100644 src/config/secrets/secrets.module.ts create mode 100644 src/config/stellar-networks.spec.ts create mode 100644 src/config/stellar-networks.ts diff --git a/.env.example b/.env.example index 5b3ca7c..3b355b5 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,26 @@ NODE_ENV=development PORT=3000 APP_URL=http://localhost:3001 +# 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= # 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 583fedc..15d2acc 100644 --- a/docs/API.md +++ b/docs/API.md @@ -9,6 +9,10 @@ | Events | `GET /events`, `GET /events/:id`, `POST /organizations/:id/events`, `POST /events/:id/ticket-types`, `POST /events/:id/publish` + `confirm-publish` | | Tickets | `POST /tickets/issue` \| `purchase` + confirm variants, `GET /tickets/verify/:qrSecret`, `GET /tickets/mine`, `GET /tickets/resale`, `GET /tickets/offline-public-keys`, `GET /tickets/:ticketId/offline-token`, and per-ticket `transfer` / `check-in` / `revoke` / `list-resale` / `cancel-resale` / `buy-resale` + their `confirm-*` counterparts | +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`. + Every `confirm-*` endpoint relays a wallet-signed XDR envelope produced by the matching build endpoint — see the root README for the full non-custodial flow. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index dc00fc9..dda7dee 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -7,3 +7,78 @@ run with a missing or malformed value. See [`docs/AUTHENTICATION.md`](AUTHENTICATION.md) / [`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. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 20a0c38..56b9eba 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -7,4 +7,6 @@ 5. Set `TICKETING_CONTRACT_ID` to the mainnet-deployed contract address 6. Set `STELLAR_NETWORK=mainnet` and a production `SOROBAN_RPC_URL` 7. Rotate `JWT_SECRET` and `PLATFORM_SIGNER_SECRET` out of any shared - `.env` file into a real secrets manager before going live + `.env` file into a real secrets manager before going live. `JWT_SECRET` + can be loaded through `SECRETS_PROVIDER=file` or a custom provider — see + the "Secret providers" section of `docs/CONFIGURATION.md` diff --git a/src/app.module.ts b/src/app.module.ts index 8eab11d..60b1cb4 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,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'; @@ -27,6 +28,7 @@ import { DomainExceptionFilter } from './common/filters/domain-exception.filter' imports: [ ConfigModule.forRoot({ isGlobal: true, validate }), 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 01ab646..2c73d77 100644 --- a/src/config/env.validation.spec.ts +++ b/src/config/env.validation.spec.ts @@ -45,4 +45,146 @@ describe('env.validate', () => { delete (config as Record).DATABASE_URL; 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(); + }); + }); }); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index b904ee7..07c8881 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -4,9 +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 { @IsIn(['development', 'test', 'production']) @@ -15,6 +21,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; + @IsInt() @IsOptional() MAX_ACTIVE_RESALE_LISTINGS_PER_USER?: number; @@ -22,9 +36,23 @@ class EnvironmentVariables { @IsString() DATABASE_URL!: string; + /// 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; @IsString() APP_URL!: string; @@ -33,7 +61,7 @@ class EnvironmentVariables { @IsString() SOROBAN_RPC_URL!: string; - @IsIn(['testnet', 'futurenet', 'mainnet']) + @IsIn(STELLAR_NETWORKS) STELLAR_NETWORK!: string; /// Deployed `ticketing` contract's C... address. @@ -79,6 +107,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, @@ -89,5 +135,12 @@ 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('; ')}`, + ); + } + return validated; } 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 1f9a01c..bc634be 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,11 +3,13 @@ import { ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import helmet from 'helmet'; import { AppModule } from './app.module'; +import { applyApiPrefix } from './config/api-prefix'; async function bootstrap() { const app = await NestFactory.create(AppModule); const config = app.get(ConfigService); + applyApiPrefix(app, config.get('API_PREFIX')); app.use(helmet()); app.enableCors({ origin: config.getOrThrow('APP_URL'), diff --git a/src/stellar/stellar.service.ts b/src/stellar/stellar.service.ts index 1789aa9..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://'), }); @@ -350,4 +347,4 @@ export class StellarService { ); return 'Valid'; } -} \ No newline at end of file +}