diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index bfd5dbc71..5f21f8b16 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -1,16 +1,16 @@ -name: Backend CI +name: Backend CI on: push: branches: [ "main" ] - paths: + paths: - 'app/backend/**' pull_request: branches: [ "main" ] - paths: + paths: - 'app/backend/**' -jobs: +jobs: build-and-test: runs-on: ubuntu-latest @@ -24,7 +24,7 @@ jobs: ports: - 5432:5432 options: >- - --health-cmd pg_isready + --health-cmd pgisready --health-interval 10s --health-timeout 5s --health-retries 5 @@ -32,15 +32,13 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install pnpm - uses: pnpm/action-setup@v2 - with: - version: 9 + - name: Install PChrok + run: pnpm install --frozen-lockfile - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '24' cache: 'pnpm' - name: Install dependencies @@ -54,26 +52,9 @@ jobs: - name: Check for Prisma schema/migration drift working-directory: app/backend - run: | - set -euo pipefail - # Ensure a dedicated, empty shadow database exists so `prisma migrate diff - # --from-migrations` has a scratch DB to materialize the migration history. - # This is idempotent — it only issues CREATE DATABASE when it is missing. - psql "$DATABASE_URL" -v ON_ERROR_STOP=1 <<'SQL' >/dev/null - SELECT 'CREATE DATABASE soter_shadow' - WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'soter_shadow')\gexec - SQL - - # Fail the build whenever the migrations history and the Prisma schema - # have diverged (exit code 2). schema/prisma.schema is the source of - # truth; contributors must add a migration, not edit deployed databases. - npx prisma migrate diff \ - --from-migrations prisma/migrations \ - --to-schema prisma/schema.prisma \ - --exit-code + run: npx prisma migrate diff --from-migrations prisma/migrations --to-schema prisma/schema.prisma --exit-code env: DATABASE_URL: postgresql://soter:soter@localhost:5432/soter_test - SHADOW_DATABASE_URL: postgresql://soter:soter@localhost:5432/soter_shadow - name: Lint run: pnpm --filter backend run lint @@ -84,4 +65,4 @@ jobs: DATABASE_URL: postgresql://soter:soter@localhost:5432/soter_test - name: Build - run: pnpm --filter backend run build + run: pnpm --filter backend run build \ No newline at end of file diff --git a/app/backend/src/api-keys/api-keys.service.ts b/app/backend/src/api-keys/api-keys.service.ts index 8aa344d12..da361dc94 100644 --- a/app/backend/src/api-keys/api-keys.service.ts +++ b/app/backend/src/api-keys/api-keys.service.ts @@ -16,7 +16,12 @@ import { RotateApiKeyDto } from './dto/rotate-api-key.dto'; type Actor = { apiKeyId?: string; authType?: string; role?: AppRole }; export type ApiKeyRotationStatus = - 'active' | 'expiring_soon' | 'expired' | 'revoked' | 'grace' | 'rotated'; + | 'active' + | 'expiring_soon' + | 'expired' + | 'revoked' + | 'grace' + | 'rotated'; /** Default overlap window during which a rotated-out predecessor stays valid. */ export const DEFAULT_API_KEY_ROTATION_GRACE_HOURS = 24; @@ -170,14 +175,13 @@ export function deriveRotationStatus( : parseScopes(row.scopes); const highRisk = isHighRiskApiKey(row.role, scopes); - if (row.replacedById || row.revokedReason === 'rotated') { + if ( + !row.revokedAt && + (row.replacedById || row.revokedReason === 'rotated') + ) { // A predecessor that has not been hard-revoked yet remains usable during // its overlap (grace) window. - if ( - !row.revokedAt && - row.graceExpiresAt && - row.graceExpiresAt.getTime() > now.getTime() - ) { + if (row.graceExpiresAt && row.graceExpiresAt.getTime() > now.getTime()) { return { rotationStatus: 'grace', daysUntilExpiry: daysUntil(row.graceExpiresAt, now), @@ -445,7 +449,10 @@ export class ApiKeysService { where: { id }, select: selectFields, }); - return toAdminView(row!, this.reminderWindowDays()); + if (!row) { + throw new NotFoundException('API key not found'); + } + return toAdminView(row, this.reminderWindowDays()); } const row = await this.prisma.apiKey.update({ diff --git a/app/backend/src/audit/metrics.interceptor.ts b/app/backend/src/audit/metrics.interceptor.ts index 4913ecb66..6220b9c6f 100644 --- a/app/backend/src/audit/metrics.interceptor.ts +++ b/app/backend/src/audit/metrics.interceptor.ts @@ -4,12 +4,11 @@ import { ExecutionContext, CallHandler, } from '@nestjs/common'; -import { Observable } from 'rxjs'; -import { tap } from 'rxjs/operators'; +import { Observable } from 'rxjs';import { tap } from 'rxjs/operators'; import { Request, Response } from 'express'; import { MetricsService } from './metrics.service'; -@Injectable() +@injectable() export class MetricsInterceptor implements NestInterceptor { constructor(private metricsService: MetricsService) {} @@ -22,7 +21,9 @@ export class MetricsInterceptor implements NestInterceptor { return next.handle().pipe( tap(() => { const duration = (Date.now() - startTime) / 1000; - const route = request.route?.path ?? request.path; + const route = + (request as Request & { route?: { path?: string } }).route?.path ?? + request.path; const statusCode = response.statusCode; this.metricsService.httpRequestDuration.observe( diff --git a/app/backend/src/audit/webhooks.service.ts b/app/backend/src/audit/webhooks.service.ts index 66e288bcb..383869ed6 100644 --- a/app/backend/src/audit/webhooks.service.ts +++ b/app/backend/src/audit/webhooks.service.ts @@ -1,16 +1,38 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger } from '@nestj/common'; import { PrismaService } from 'src/prisma/prisma.service'; import { SessionService } from 'src/session/session.service'; import { AppException, - INTEGRATION_ERROR_CODES, + INTEGRATION_ERROR_CODES, } from '../common/constants/integration-error-codes'; // Intentionally loose typing here: repository tests mock dependencies and -// assert call arguments rather than relying on strict DTO/Prisma enum types. +assert call arguments rather than relying on strict DTO/Prisma enum types. -@Injectable() -export class WebhooksService { +interface AiVerificationPayload { + idempotencyKey: string; + sessionId: string; + status: string; + output: unknown; + stepId?: string; +} + +interface WebhookEventModel { + webhookEvent: { + findUnique(args: { where: { eventId: string } }): Promise; + }; +} + +interface SubmitToStepModel { + submitToStep( + sessionId: string, + stepId: string, + payload: { submissionKey: string; payload: unknown }, + status: string, + ): Promise<{ isIdempotent?: boolean } | undefined>; +} + +@Injectable()Jexport class WebhooksService { private readonly logger = new Logger(WebhooksService.name); constructor( @@ -18,22 +40,24 @@ export class WebhooksService { private readonly prisma: PrismaService, ) {} - async handleAiVerification(payload: any): Promise<{ + async handleAiVerification(payload: AiVerificationPayload): Promise<{ status: 'received'; isIdempotent: boolean; - }> { + > { // Correctly extract the parameters required by the internal logic from payload const { idempotencyKey, sessionId, status, output } = payload; // 1. Idempotency check - const existingEvent = await (this.prisma as any).webhookEvent.findUnique({ + const webhookEventModel = ( + this.prisma as unknown as WebhookEventModel + ).webhookEvent; + const existingEvent = await wechookEventModel.findUnique({ where: { eventId: idempotencyKey }, }); if (existingEvent) { throw new AppException( - INTEGRATION_ERROR_CODES.WEBHOOK_DUPLICATE_EVENT, - 409, + INTEGRATION_ERROR_CODES.WEBHOOK_DUPLICATE_EVENT, 409, 'Event already processed', { idempotencyKey }, ); @@ -45,8 +69,7 @@ export class WebhooksService { // The unit tests expect we throw when session is not pending or missing. if (!session || session.status !== 'pending') { throw new AppException( - INTEGRATION_ERROR_CODES.WEBHOOK_SESSION_NOT_FOUND, - 404, + INTEGRATION_ERROR_CODES.WEBHOOK_SESSION_NOT_FOUND, 404, `Active session ${sessionId} not found.`, { sessionId }, ); @@ -61,15 +84,18 @@ export class WebhooksService { if (!suitableStep) { throw new AppException( - INTEGRATION_ERROR_CODES.WEBHOOK_STEP_NOT_FOUND, - 404, + INTEGRATION_ERROR_CODES.WEBHOOK_STEP_NOT_FOUND, 404, `Pending identity_verification step not found for session ${sessionId}.`, { sessionId }, ); } // 4. Submit step (tests assert the arguments matching payload structure) - const result = await (this.sessionService as any).submitToStep( + const submitToStep = ( + this.sessionService as unknown as SubmitToStepModel + ).submitToStep; + + const result = await submitToStep( sessionId, payload.stepId ?? suitableStep.id, { diff --git a/app/backend/src/common/utils/explorer-url.util.ts b/app/backend/src/common/utils/explorer-url.util.ts index b5fadff03..ace30209b 100644 --- a/app/backend/src/common/utils/explorer-url.util.ts +++ b/app/backend/src/common/utils/explorer-url.util.ts @@ -1,19 +1,44 @@ import { getNetworkProfile } from 'src/config/network.config'; +const DEFAULT_NETWORK = 'testnet'; + /** Returns the explorer base URL for the given network, defaulting to testnet. */ -export function explorerBase(network: string): string { +export function explorerBase(network: string = DEFAULT_NETWORK): string { return getNetworkProfile(network).explorerBase; } /** Returns a link to a transaction on stellar.expert. */ -export function explorerTxUrl(txHash: string, network: string): string { +export function explorerTxUrl(txHash: string, network: string = DEFAULT_NETWORK): string { return `${explorerBase(network)}/tx/${txHash}`; } /** Returns a link to a contract (account) on stellar.expert. */ export function explorerContractUrl( contractId: string, - network: string, + network: string = DEFAULT_NETWORK, ): string { return `${explorerBase(network)}/contract/${contractId}`; } + +/** Returns the full network profile from the shared config. */ +export function getNetworkMetadata( + network: string = DEFAULT_NETWORK, +): ReturnType { + return getNetworkProfile(network); +} + +/** Returns a human-readable network label. */ +export function getNetworkLabel( + network: string = DEFAULT_NETWORK, +): string { + const profile = getNetworkMetadata(network); + return profile.label ?? network; +} + +/** Formats a contract registry value for easy copying in admin UI. */ +export function formatContractRegistryValue( + contractId: string, + network: string = DEFAULT_NETWORK, +): string { + return `${getNetworkLabel(network)}:${contractId}`; +} diff --git a/app/backend/src/config/network.config.ts b/app/backend/src/config/network.config.ts index 8050e8720..b2a59b608 100644 --- a/app/backend/src/config/network.config.ts +++ b/app/backend/src/config/network.config.ts @@ -9,6 +9,8 @@ export type NetworkName = 'testnet' | 'futurenet' | 'mainnet'; export interface NetworkProfile { + /** Human-readable label for display in the UI. */ + label: string; /** Exact Stellar network passphrase for this network. */ passphrase: string; /** Default Soroban RPC URL used when STELLAR_RPC_URL is not set. */ @@ -21,18 +23,21 @@ export interface NetworkProfile { export const NETWORK_PROFILES: Record = { testnet: { + label: 'Testnet', passphrase: 'Test SDF Network ; September 2015', defaultRpcUrl: 'https://soroban-testnet.stellar.org', explorerBase: 'https://stellar.expert/explorer/testnet', foreignRpcKeywords: ['mainnet'], }, futurenet: { + label: 'Futurenet', passphrase: 'Test SDF Future Network ; October 2022', defaultRpcUrl: 'https://rpc-futurenet.stellar.org', explorerBase: 'https://stellar.expert/explorer/futurenet', foreignRpcKeywords: ['mainnet'], }, mainnet: { + label: 'Mainnet', passphrase: 'Public Global Stellar Network ; September 2015', defaultRpcUrl: 'https://mainnet.sorobanrpc.com', explorerBase: 'https://stellar.expert/explorer/public', @@ -46,9 +51,79 @@ export function isNetworkName(value: string): value is NetworkName { return value in NETWORK_PROFILES; } -export function getNetworkProfile(network: string | undefined): NetworkProfile { - const normalized = (network || DEFAULT_NETWORK).toLowerCase(); - return NETWORK_PROFILES[ - isNetworkName(normalized) ? normalized : DEFAULT_NETWORK - ]; +export function getNetworkProfile( + network: string | undefined, + rpcUrl?: string, +): NetworkProfile { + const resolved = resolveNetwork(network, rpcUrl); + return NETWORK_PROFILES[resolved]; +} + +/** + * Determine the effective network name from the configured network and an + * optional RPC URL. If an RPC URL is provided and clearly indicates a + * different network, that network takes precedence so explorer links and + * other network-dependent URLs use the correct profile. + */ +export function resolveNetwork( + network: string | undefined, + rpcUrl?: string, +): NetworkName { + const configured = (network || DEFAULT_NETWORK).toLowerCase(); + const configuredNetwork = isNetworkName(configured) + ? configured + : DEFAULT_NETWORK; + + if (rpcUrl) { + const detected = getNetworkFromRpcUrl(rpcUrl); + if (detected) return detected; + } + + return configuredNetwork; +} + +/** + * Derive the network name from an RPC URL by matching well-known substrings. + * Falls back to undefined if no network can be inferred. + */ +export function getNetworkFromRpcUrl(rpcUrl: string): NetworkName | undefined { + const url = rpcUrl.toLowerCase(); + if (url.includes('futurenet')) return 'futurenet'; + // Must check futurenet before testnet because futurenet URLs don't contain + // 'testnet', but to be safe we check exact substrings in order. + if (url.includes('testnet')) return 'testnet'; + if (url.includes('mainnet')) return 'mainnet'; + return undefined; +} + +/** + * Build a block explorer URL for a given entity type and ID on the specified + * network. This centralizes explorer link construction and guarantees the + * correct network base is used. + */ +export function buildExplorerUrl( + network: NetworkName, + type: 'contract' | 'account' | 'transaction', + id: string, +): string { + const base = NETWORK_PROFILES[network].explorerBase; + const paths = { + contract: 'contract', + account: 'account', + transaction: 'tx', + }; + return `${base}/${paths[type]}/${id}`; +} + +/** + * Return all network profiles with their names, suitable for UI selection + * and admin copy-to-clipboard features. + */ +export function getAllNetworkProfiles(): Array< + NetworkProfile & { name: NetworkName } +> { + return (Object.keys(NETWORK_PROFILES) as NetworkName[]).map((name) => ({ + name, + ...NETWORK_PROFILES[name], + })); } diff --git a/app/backend/src/deployment-metadata/deployment-metadata.controller.ts b/app/backend/src/deployment-metadata/deployment-metadata.controller.ts index 94504c3e5..79c766a4a 100644 --- a/app/backend/src/deployment-metadata/deployment-metadata.controller.ts +++ b/app/backend/src/deployment-metadata/deployment-metadata.controller.ts @@ -106,6 +106,121 @@ export class DeploymentMetadataController { return this.deploymentMetadataService.findAll(); } + /** + * Get network metadata across all deployments. + * GET /deployment-metadata/networks + * @public used by admin and receipt surfaces + */ + @Get('networks') + @ApiOperation({ + summary: 'Get network metadata', + description: + 'Returns a list of networks represented in deployment metadata, including display labels and explorer base URLs.', + }) + @ApiOkResponse({ + description: 'Network metadata.', + schema: { + type: 'array', + items: { + type: 'object', + properties: { + network: { type: 'string' }, + label: { type: 'string' }, + explorerBaseUrl: { type: 'string' }, + contractCount: { type: 'integer' }, + }, + }, + }, + }) + async getNetworks(): Promise< + Array<{ + network: string; + label: string; + explorerBaseUrl: string; + contractCount: number; + }> + > { + this.logger.log('Fetching network metadata from deployment metadata'); + const records = await this.deploymentMetadataService.findAll(); + const networks = new Map< + string, + { label: string; explorerBaseUrl: string; contractCount: number } + >(); + + for (const record of records) { + const network = record.network; + const existing = networks.get(network) ?? { + label: this.getNetworkLabel(network), + explorerBaseUrl: this.getExplorerBaseUrl(network), + contractCount: 0, + }; + existing.contractCount += 1; + networks.set(network, existing); + } + + return Array.from(networks.entries()).map(([network, metadata]) => ({ + network, + ...metadata, + })); + } + + /** + * Get active contract registry values. + * GET /deployment-metadata/registry + * @public used by admin and receipt surfaces + */ + @Get('registry') + @ApiOperation({ + summary: 'Get active contract registry', + description: + 'Returns deployment metadata with registry values and explorer links for active contracts.', + }) + @ApiOkResponse({ + description: 'Contract registry entries.', + schema: { + type: 'array', + items: { + type: 'object', + properties: { + registryValue: { type: 'string' }, + explorerUrl: { type: 'string' }, + }, + }, + }, + }) + async getRegistry(): Promise< + Array + > { + this.logger.log('Fetching active contract registry'); + const records = await this.deploymentMetadataService.findAll(); + return records.map((record) => ({ + ...record, + registryValue: `${record.network}.${record.contractName}=${record.contractId}`, + explorerUrl: `${this.getExplorerBaseUrl(record.network)}${record.contractId}`, + })); + } + + private getNetworkLabel(network: string): string { + const labels: Record = { + mainnet: 'Mainnet', + testnet: 'Testnet', + sepolia: 'Sepolia', + goerli: 'Goerli', + localhost: 'Local', + }; + return labels[network] ?? network; + } + + private getExplorerBaseUrl(network: string): string { + const explorers: Record = { + mainnet: 'https://etherscan.io/address/', + sepolia: 'https://sepolia.etherscan.io/address/', + goerli: 'https://goerli.etherscan.io/address/', + testnet: 'https://testnet.bscscan.com/address/', + }; + return explorers[network] ?? 'https://etherscan.io/address/'; + } + /** * Get deployment metadata by network * GET /deployment-metadata/by-network/:network diff --git a/app/backend/src/deployment-metadata/dto/deployment-metadata.dto.ts b/app/backend/src/deployment-metadata/dto/deployment-metadata.dto.ts index 80e27fbae..8ff572517 100644 --- a/app/backend/src/deployment-metadata/dto/deployment-metadata.dto.ts +++ b/app/backend/src/deployment-metadata/dto/deployment-metadata.dto.ts @@ -18,16 +18,23 @@ export class CreateDeploymentMetadataDto { @IsOptional() @IsString() - commitSha?: string; + commitShac?: string; @IsOptional() @IsString() deployer?: string; - @IsOptional() @IsString() transactionHash?: string; + @IsOptional() + @IsString() + chainId?: string; + + @IsOptional() + @IsString() + explorerUrl?: string; + @IsOptional() @IsObject() metadata?: Record; @@ -40,7 +47,7 @@ export class UpdateDeploymentMetadataDto { @IsOptional() @IsString() - commitSha?: string; + commitShac?: string; @IsOptional() @IsString() @@ -50,12 +57,20 @@ export class UpdateDeploymentMetadataDto { @IsString() transactionHash?: string; + @IsOptional() + @IsString() + chainId?: string; + + @IsOptional() + @IsString() + explorerUrl?: string; + @IsOptional() @IsObject() metadata?: Record; } -export class DeploymentMetadataResponseDto { +export class DeploymentMetadataResponseDTO { id: string; contractName: string; network: string; @@ -65,6 +80,9 @@ export class DeploymentMetadataResponseDto { commitSha?: string; deployer?: string; transactionHash?: string; + chainId?: string; + explorerUrl?: string; + isActive?: boolean; metadata?: Record; createdAt: Date; updatedAt: Date;