Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ DATABASE_URL=postgresql://user:password@localhost:5432/mux_db?sslmode=require
# ------------------------------------------------------------
PORT=3000

# Git commit SHA of the running build, exposed via GET /health for build
# identity/traceability. Injected by CI/Docker (--build-arg GIT_SHA=...);
# defaults to "unknown" if not set.
GIT_SHA=

# Maximum JSON/form request body size in bytes (default: 102400 / 100 KiB).
# Requests above this limit receive HTTP 413.
JSON_BODY_LIMIT_BYTES=102400
Expand Down
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ COPY --from=builder /app/dist ./dist
COPY --from=builder /app/src/generated ./src/generated
COPY prisma ./prisma

# Build identity: pass --build-arg GIT_SHA=$(git rev-parse HEAD) so it's
# exposed via GET /health. Defaults to "unknown" for local/dev builds.
ARG GIT_SHA=unknown
ENV GIT_SHA=$GIT_SHA

EXPOSE 3000

CMD ["node", "dist/main"]
13 changes: 13 additions & 0 deletions docs/MAINNET-PAYMENT-FEATURE-FLAG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Mainnet Payment Submit Feature Flag

- `FEATURE_MAINNET_PAYMENT_SUBMIT` (boolean, default: false)
- When `true`, `POST /transactions/fee-bump` requests with `network: "MAINNET"` are submitted to Horizon mainnet as normal.
- When `false` or unset, MAINNET submissions are rejected with HTTP 403 (Forbidden) and message: "Mainnet payment submission is not available at this time. (Flag: mainnet_payment_submit)". `TESTNET` submissions are unaffected — the flag is only consulted when `network === "MAINNET"`.

Notes:
- Implemented as a kill-switch check inside `FeeBumpService.submitFeeBump` (not the route-level `FeatureFlagGuard`), because the decision depends on the `network` field in the request body rather than being fixed per-route.
- Reuses the existing `FeatureFlagService.isEnabled()` helper and the `FEATURE_<FLAG_NAME>` env var convention (e.g. `FEATURE_MAINNET_PAYMENT_SUBMIT=true`).
- Rejections happen before any wallet key material is decrypted or any call to Horizon is made.

Operational guidance:
- Keep this flag off in production until mainnet payment submission has been reviewed and approved for general availability; flip it on per-environment via env/secret config.
30 changes: 24 additions & 6 deletions src/auth/auth-metrics.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,15 @@
* for every meaningful auth outcome.
*/
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { AuthOrchestrator } from './auth-orchestrator.service';
import {
BadRequestException,
ForbiddenException,
ServiceUnavailableException,
} from '@nestjs/common';
import {
AuthOrchestrator,
EXTERNAL_AUTH_FAILURE_MESSAGE,
} from './auth-orchestrator.service';
import { AuthMetricsService } from './auth-metrics.service';
import { IdempotentUserService } from '../users/idempotent-user.service';
import { WalletCreationOrchestrator } from '../wallets/wallet-creation-orchestrator.service';
Expand Down Expand Up @@ -202,12 +209,23 @@ describe('AuthOrchestrator — metrics integration', () => {
});

describe('unknown error', () => {
it('records failure_unknown for generic DB errors', async () => {
it('records failure_unknown for generic DB errors, without leaking the raw cause', async () => {
userService.findOrCreateUser.mockRejectedValue(new Error('DB down'));

await expect(
orchestrator.handleAuthentication({ authId: 'auth-abc' }),
).rejects.toThrow('Authentication failed: DB down');
let caught: unknown;
try {
await orchestrator.handleAuthentication({ authId: 'auth-abc' });
} catch (err) {
caught = err;
}

expect(caught).toBeInstanceOf(ServiceUnavailableException);
expect((caught as ServiceUnavailableException).message).toBe(
EXTERNAL_AUTH_FAILURE_MESSAGE,
);
expect((caught as ServiceUnavailableException).message).not.toContain(
'DB down',
);

const snap = metricsService.getSnapshot();
expect(snap.outcomes.failure_unknown).toBe(1);
Expand Down
15 changes: 15 additions & 0 deletions src/auth/auth-orchestrator.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,21 @@ export class AuthOrchestratorController {
},
},
})
@ApiResponse({
status: 503,
description:
'Service Unavailable — an unclassified downstream failure occurred ' +
'(e.g. database or Stellar network unreachable). The message is a ' +
'consolidated, generic string; internal error details are never ' +
'exposed to callers and are logged server-side only.',
schema: {
example: {
statusCode: 503,
message: 'Authentication failed. Please try again later.',
error: 'Service Unavailable',
},
},
})
@Public()
@Post('authenticate')
@UseGuards(AuthRateLimitGuard)
Expand Down
44 changes: 35 additions & 9 deletions src/auth/auth-orchestrator.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@
* - Error propagation from collaborators
*/
import { Test, TestingModule } from '@nestjs/testing';
import { AuthOrchestrator } from './auth-orchestrator.service';
import { ServiceUnavailableException } from '@nestjs/common';
import {
AuthOrchestrator,
EXTERNAL_AUTH_FAILURE_MESSAGE,
} from './auth-orchestrator.service';
import { IdempotentUserService } from '../users/idempotent-user.service';
import { WalletCreationOrchestrator } from '../wallets/wallet-creation-orchestrator.service';
import { WalletNetwork, WalletStatus } from '../wallets/domain/wallet.model';
Expand Down Expand Up @@ -229,17 +233,28 @@ describe('AuthOrchestrator (integration harness)', () => {
// -------------------------------------------------------------------------

describe('error propagation', () => {
it('wraps user service errors in an Authentication failed error', async () => {
it('wraps user service errors in a consolidated, generic 503 — never leaking the raw cause', async () => {
userService.findOrCreateUser.mockRejectedValue(
new Error('DB unavailable'),
);

await expect(
orchestrator.handleAuthentication({ authId: 'auth-abc' }),
).rejects.toThrow('Authentication failed: DB unavailable');
let caught: unknown;
try {
await orchestrator.handleAuthentication({ authId: 'auth-abc' });
} catch (err) {
caught = err;
}

expect(caught).toBeInstanceOf(ServiceUnavailableException);
expect((caught as ServiceUnavailableException).message).toBe(
EXTERNAL_AUTH_FAILURE_MESSAGE,
);
expect((caught as ServiceUnavailableException).message).not.toContain(
'DB unavailable',
);
});

it('wraps wallet creation errors in an Authentication failed error', async () => {
it('wraps wallet creation errors in a consolidated, generic 503 — never leaking the raw cause', async () => {
userService.findOrCreateUser.mockResolvedValue({
user: makeUser(),
isNewUser: true,
Expand All @@ -249,9 +264,20 @@ describe('AuthOrchestrator (integration harness)', () => {
new Error('Stellar unavailable'),
);

await expect(
orchestrator.handleAuthentication({ authId: 'auth-abc' }),
).rejects.toThrow('Authentication failed: Stellar unavailable');
let caught: unknown;
try {
await orchestrator.handleAuthentication({ authId: 'auth-abc' });
} catch (err) {
caught = err;
}

expect(caught).toBeInstanceOf(ServiceUnavailableException);
expect((caught as ServiceUnavailableException).message).toBe(
EXTERNAL_AUTH_FAILURE_MESSAGE,
);
expect((caught as ServiceUnavailableException).message).not.toContain(
'Stellar unavailable',
);
});
});

Expand Down
16 changes: 15 additions & 1 deletion src/auth/auth-orchestrator.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ForbiddenException,
BadRequestException,
HttpException,
ServiceUnavailableException,
} from '@nestjs/common';
import {
IdempotentUserService,
Expand All @@ -22,6 +23,16 @@ import { IdempotencyService } from '../common/idempotency/idempotency.service';
import { AuthMetricsService } from './auth-metrics.service';
import { RequestContextService } from '../common/request-context/request-context.service';

/**
* Single consolidated message returned to external callers for any
* unclassified authentication failure (downstream DB/Stellar/wallet errors,
* etc). Never interpolates the underlying error — those details are logged
* server-side only, so partner-facing responses stay consistent and never
* leak internal infrastructure state.
*/
export const EXTERNAL_AUTH_FAILURE_MESSAGE =
'Authentication failed. Please try again later.';

export interface AuthenticationRequest {
authId: string;
email?: string;
Expand Down Expand Up @@ -327,7 +338,10 @@ export class AuthOrchestrator {
// Only record 'failure_unknown' if not already classified above
const latency = Date.now() - startTime;
this.authMetrics.recordAttempt('failure_unknown', latency);
throw new Error(`Authentication failed: ${error.message}`);
// Consolidated, generic message — the real cause (DB/Stellar/etc) was
// already logged above via this.logger.error(); never forward
// downstream error text to external callers.
throw new ServiceUnavailableException(EXTERNAL_AUTH_FAILURE_MESSAGE);
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/balance-indexer/dto/balance-filter.dto.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { AssetType } from '../domain/balance.model';
import { IsStellarPublicKey } from '../../common/stellar/is-stellar-public-key.validator';

export class BalanceFilterDto {
@ApiProperty({
Expand All @@ -27,7 +28,7 @@ export class BalanceFilterDto {
description: 'Filter by asset issuer',
required: false,
})
@IsString({ message: 'assetIssuer must be a string' })
@IsOptional()
@IsStellarPublicKey()
assetIssuer?: string;
}
3 changes: 2 additions & 1 deletion src/balance-indexer/dto/reconcile-balance.dto.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { IsEnum, IsOptional, IsString, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { AssetType } from '../domain/balance.model';
import { IsStellarPublicKey } from '../../common/stellar/is-stellar-public-key.validator';

export class ReconcileBalanceDto {
@ApiProperty({
Expand All @@ -26,7 +27,7 @@ export class ReconcileBalanceDto {
description: 'Asset issuer account ID (required if assetType is CREDIT_ALPHANUM4 or CREDIT_ALPHANUM12)',
required: false,
})
@IsString({ message: 'assetIssuer must be a string' })
@IsOptional()
@IsStellarPublicKey()
assetIssuer?: string;
}
63 changes: 63 additions & 0 deletions src/common/stellar/is-stellar-public-key.validator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { validate } from 'class-validator';
import { IsStellarPublicKey } from './is-stellar-public-key.validator';

class Fixture {
@IsStellarPublicKey()
publicKey: string;
}

const VALID_KEY =
'GBUQWP3BOUZX34ZONKXRBTLNNDOWR5HLCVPL2B4XNCLJTLMUMLTSOGBM';

describe('IsStellarPublicKey', () => {
it('passes for a valid Stellar public key (checksum-correct)', async () => {
const fixture = new Fixture();
fixture.publicKey = VALID_KEY;

const errors = await validate(fixture);

expect(errors).toHaveLength(0);
});

it('fails for a checksum-corrupted key that still matches the shape regex', async () => {
const fixture = new Fixture();
// Flip the last character — same length/prefix, invalid checksum.
fixture.publicKey = VALID_KEY.slice(0, -1) + (VALID_KEY.endsWith('A') ? 'B' : 'A');

const errors = await validate(fixture);

expect(errors).toHaveLength(1);
expect(errors[0].constraints).toEqual(
expect.objectContaining({
isStellarPublicKey: expect.stringContaining('publicKey'),
}),
);
});

it('fails for a secret seed (S...) passed where a public key is expected', async () => {
const fixture = new Fixture();
fixture.publicKey = 'SBUQWP3BOUZX34ZONKXRBTLNNDOWR5HLCVPL2B4XNCLJTLMUMLTSOGBM';

const errors = await validate(fixture);

expect(errors).toHaveLength(1);
});

it('fails for non-string input', async () => {
const fixture = new Fixture();
(fixture as unknown as { publicKey: unknown }).publicKey = 12345;

const errors = await validate(fixture);

expect(errors).toHaveLength(1);
});

it('fails for an empty string', async () => {
const fixture = new Fixture();
fixture.publicKey = '';

const errors = await validate(fixture);

expect(errors).toHaveLength(1);
});
});
33 changes: 33 additions & 0 deletions src/common/stellar/is-stellar-public-key.validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {
registerDecorator,
ValidationOptions,
ValidationArguments,
} from 'class-validator';
import { StrKeyHelper } from '../../key-management/utils';

/**
* Validates that a property is a well-formed Stellar Ed25519 public key
* (StrKey "G..." address), using stellar-sdk's checksum validation rather
* than a bare regex — catches typos/bit-flips that a shape-only check would miss.
*/
export function IsStellarPublicKey(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isStellarPublicKey',
target: object.constructor,
propertyName,
options: validationOptions,
validator: {
validate(value: unknown, _args: ValidationArguments) {
return (
typeof value === 'string' &&
StrKeyHelper.isValidEd25519PublicKey(value)
);
},
defaultMessage(args: ValidationArguments) {
return `${args.property} must be a valid Stellar public key (StrKey "G..." address)`;
},
},
});
};
}
Loading
Loading