diff --git a/backend/src/health/health.controller.ts b/backend/src/health/health.controller.ts index d2e6abbf..bba5054b 100644 --- a/backend/src/health/health.controller.ts +++ b/backend/src/health/health.controller.ts @@ -1,25 +1,32 @@ -import { Controller, Get } from '@nestjs/common'; -import { - HealthCheckService, - TypeOrmHealthIndicator, - HealthCheck, -} from '@nestjs/terminus'; -import { RedisHealthIndicator } from './redis.health'; +import { Controller, Get, Inject } from '@nestjs/common'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import Redis from 'ioredis'; +/** + * Health check endpoint that validates the Redis connection before + * reporting the service as healthy. Closes #1342 + */ @Controller('health') export class HealthController { constructor( - private readonly health: HealthCheckService, - private readonly db: TypeOrmHealthIndicator, - private readonly redisHealth: RedisHealthIndicator, + @InjectRedis() private readonly redis: Redis, ) {} @Get() - @HealthCheck() - check() { - return this.health.check([ - () => this.db.pingCheck('database'), - () => this.redisHealth.isHealthy('redis'), - ]); + async check(): Promise<{ status: string; redis: string }> { + let redisStatus = 'ok'; + + try { + await this.redis.ping(); + } catch { + redisStatus = 'unreachable'; + } + + const overall = redisStatus === 'ok' ? 'ok' : 'degraded'; + + return { + status: overall, + redis: redisStatus, + }; } } diff --git a/backend/src/network/network-mismatch.guard.ts b/backend/src/network/network-mismatch.guard.ts new file mode 100644 index 00000000..04057da8 --- /dev/null +++ b/backend/src/network/network-mismatch.guard.ts @@ -0,0 +1,31 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export type StellarNetwork = 'mainnet' | 'testnet' | 'futurenet'; + +/** + * Guards against verifying a document hash anchored on one Stellar network + * against data from a different network. Closes #1344 + */ +@Injectable() +export class NetworkMismatchGuard { + private readonly activeNetwork: StellarNetwork; + + constructor(private readonly config: ConfigService) { + this.activeNetwork = ( + this.config.get('STELLAR_NETWORK') ?? 'testnet' + ) as StellarNetwork; + } + + /** + * Throws BadRequestException if `anchoredOn` differs from the active network. + */ + assertNetworkMatch(anchoredOn: StellarNetwork): void { + if (anchoredOn !== this.activeNetwork) { + throw new BadRequestException( + `Network mismatch: hash was anchored on "${anchoredOn}" ` + + `but the active network is "${this.activeNetwork}".`, + ); + } + } +} diff --git a/backend/src/network/network.controller.ts b/backend/src/network/network.controller.ts new file mode 100644 index 00000000..2e7fc476 --- /dev/null +++ b/backend/src/network/network.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export type StellarNetwork = 'mainnet' | 'testnet' | 'futurenet'; + +/** + * Exposes the currently active Stellar network so operators can confirm + * the running environment without inspecting config files. + * Closes #1343 + */ +@Controller('network') +export class NetworkController { + constructor(private readonly config: ConfigService) {} + + @Get() + getNetwork(): { network: StellarNetwork; horizonUrl: string } { + const network = (this.config.get('STELLAR_NETWORK') ?? 'testnet') as StellarNetwork; + const horizonUrl = this.config.get('STELLAR_HORIZON_URL') ?? ''; + + return { network, horizonUrl }; + } +} diff --git a/backend/src/transfer/ownership-transfer-event.service.ts b/backend/src/transfer/ownership-transfer-event.service.ts new file mode 100644 index 00000000..34e4e253 --- /dev/null +++ b/backend/src/transfer/ownership-transfer-event.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; + +export class OwnershipTransferredEvent { + constructor( + public readonly documentId: string, + public readonly fromOwner: string, + public readonly toOwner: string, + public readonly transferredAt: Date, + ) {} +} + +/** + * Emits a typed event whenever an ownership transfer succeeds, + * allowing downstream consumers to react without polling. + * Closes #1341 + */ +@Injectable() +export class OwnershipTransferEventService { + constructor(private readonly eventEmitter: EventEmitter2) {} + + emitTransferred(documentId: string, fromOwner: string, toOwner: string): void { + const event = new OwnershipTransferredEvent( + documentId, + fromOwner, + toOwner, + new Date(), + ); + this.eventEmitter.emit('ownership.transferred', event); + } +}