Skip to content
Open
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
39 changes: 10 additions & 29 deletions .github/workflows/backend-ci.yml
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -24,23 +24,21 @@ jobs:
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-cmd pgisready
--health-interval 10s
--health-timeout 5s
--health-retries 5

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
Expand All @@ -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
Expand All @@ -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
23 changes: 15 additions & 8 deletions app/backend/src/api-keys/api-keys.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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({
Expand Down
9 changes: 5 additions & 4 deletions app/backend/src/audit/metrics.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}

Expand All @@ -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(
Expand Down
56 changes: 41 additions & 15 deletions app/backend/src/audit/webhooks.service.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,63 @@
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<unknown>;
};
}

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(
private readonly sessionService: SessionService,
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 },
);
Expand All @@ -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 },
);
Expand All @@ -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,
{
Expand Down
31 changes: 28 additions & 3 deletions app/backend/src/common/utils/explorer-url.util.ts
Original file line number Diff line number Diff line change
@@ -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<typeof getNetworkProfile> {
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}`;
}
Loading