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
13 changes: 13 additions & 0 deletions app/ai-service/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,19 @@ async def health_check():
return {"status": "healthy", "service": "chainforge-ai-service", "version": "1.0.0"}


@app.get("/api/v1/pii/patterns")
async def get_pii_patterns():
return {
"email": PIIScrubberService.EMAIL_REGEXES,
"phone": PIIScrubberService.PHONE_REGEXES,
"name": PIIScrubberService.NAME_REGEXES,
"location": PIIScrubberService.LOCATION_REGEXES,
"date": PIIScrubberService.DATE_REGEXES,
"id": PIIScrubberService.ID_REGEXES,
}



@app.get("/health/dependencies")
async def health_dependencies():
"""Lightweight dependency probe for staging and CI.
Expand Down
3 changes: 2 additions & 1 deletion app/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"test:e2e:ci": "prisma migrate deploy && jest --config ./test/jest-e2e.json --runInBand",
"spec:export": "ts-node --transpile-only -r tsconfig-paths/register scripts/export-spec.ts"
"spec:export": "ts-node --transpile-only -r tsconfig-paths/register scripts/export-spec.ts",
"nestjs-route-doctor": "ts-node --transpile-only -r tsconfig-paths/register scripts/nestjs-route-doctor.ts"
},
"dependencies": {
"@liaoliaots/nestjs-redis": "^10.0.0",
Expand Down
103 changes: 103 additions & 0 deletions app/backend/scripts/nestjs-route-doctor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { NestFactory } from '@nestjs/core';
import { DiscoveryService, MetadataScanner, Reflector } from '@nestjs/core';
import { AppModule } from '../src/app.module';
import { ROLES_KEY } from '../src/auth/roles.decorator';
import { NO_AUTH_STRICT_KEY } from '../src/common/decorators/no-auth-strict.decorator';

async function runRouteDoctor() {
// Use createApplicationContext for light-weight boot without opening HTTP ports
const app = await NestFactory.createApplicationContext(AppModule, { logger: false });

const discoveryService = app.get(DiscoveryService);
const metadataScanner = app.get(MetadataScanner);
const reflector = app.get(Reflector);

const controllers = discoveryService.getControllers();
let undecoratedCount = 0;

// Retrieve bypass paths from environment
const bypassEnv = process.env.PUBLIC_AUTH_BYPASS ?? '';
const bypassedPaths = bypassEnv
.split(',')
.map((p) => p.trim())
.filter(Boolean);

console.log('🔍 Running NestJS Route Doctor...');
console.log(`Bypass list: ${JSON.stringify(bypassedPaths)}`);

for (const wrapper of controllers) {
const { instance, name: controllerName } = wrapper;
if (!instance) continue;

const controllerClass = wrapper.metatype;
const classPath = Reflect.getMetadata('path', controllerClass) ?? '';

const prototype = Object.getPrototypeOf(instance);
const methodNames = metadataScanner.getAllMethodNames(prototype);

for (const methodName of methodNames) {
const handler = instance[methodName];
const methodPath = Reflect.getMetadata('path', handler);

// If methodPath is undefined, this method is not a route handler
if (methodPath === undefined) continue;

// Construct the paths to check
const methodPaths = Array.isArray(methodPath) ? methodPath : [methodPath];

for (const mPath of methodPaths) {
// Build the combined path
let fullPath = `/${classPath}/${mPath}`.replace(/\/+/g, '/').replace(/\/$/, '');
if (fullPath === '') fullPath = '/';

// Prepend api/v1 to simulate typical route prefixing in this app
const fullPathWithPrefix = `/api/v1${fullPath}`.replace(/\/+/g, '/').replace(/\/$/, '');

// Check if bypassed
const isBypassed = bypassedPaths.some((bpath) => {
const cleanBPath = bpath.replace(/^\/+|\/+$/g, '');
const cleanReqPath = fullPathWithPrefix.replace(/^\/+|\/+$/g, '');
if (cleanBPath === cleanReqPath) return true;
if (cleanReqPath.endsWith(cleanBPath)) {
const index = cleanReqPath.lastIndexOf(cleanBPath);
if (index > 0 && cleanReqPath.charAt(index - 1) === '/') return true;
}
return false;
});

if (isBypassed) {
continue;
}

// Check decorators
const requiredRoles = reflector.getAllAndOverride<string[]>(ROLES_KEY, [
handler,
controllerClass,
]);
const isNoAuthStrict = reflector.getAllAndOverride<boolean>(NO_AUTH_STRICT_KEY, [
handler,
controllerClass,
]);

if (!requiredRoles && !isNoAuthStrict) {
console.error(
`❌ Undecorated route found: ${controllerName}.${methodName} [${fullPathWithPrefix}] is missing @Roles() or @NoAuthStrict()`,
);
undecoratedCount++;
}
}
}
}

await app.close();

if (undecoratedCount > 0) {
console.error(`\n🚨 Route Doctor found ${undecoratedCount} undecorated route(s). Failing build.`);
process.exit(1);
} else {
console.log('✅ Route Doctor: All routes are properly decorated or bypassed!');
process.exit(0);
}
}

void runRouteDoctor();
8 changes: 8 additions & 0 deletions app/backend/src/app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AppService } from './app.service';
import { API_VERSIONS } from './common/constants/api-version.constants';
import { Public } from './common/decorators/public.decorator';
import { Deprecated } from './common/decorators/deprecated.decorator';
import { NoAuthStrict } from './common/decorators/no-auth-strict.decorator';

@ApiTags('App')
@Controller()
Expand Down Expand Up @@ -55,4 +56,11 @@ export class AppController {
deprecatedTest() {
return { message: 'This endpoint is deprecated' };
}

@Public()
@NoAuthStrict()
@Get('no-auth-strict-test')
noAuthStrictTest() {
return { status: 'ok', message: 'public anonymous access allowed' };
}
}
80 changes: 53 additions & 27 deletions app/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ import { JobsModule } from './jobs/jobs.module';
import { RequestCorrelationMiddleware } from './middleware/request-correlation.middleware';
import { SecurityModule } from './common/security/security.module';
import { CampaignsModule } from './campaigns/campaigns.module';
import { APP_GUARD } from '@nestjs/core';
import { APP_GUARD, DiscoveryModule } from '@nestjs/core';
import { ApiKeyGuard } from './common/guards/api-key.guard';
import { RolesGuard } from './auth/roles.guard';
import { UnexpectedAuthHeaderGuard } from './common/guards/unexpected-auth-header.guard';
import { ObservabilityModule } from './observability/observability.module';
import { ClaimsModule } from './claims/claims.module';
import { LoggingInterceptor } from './interceptors/logging.interceptor';
Expand All @@ -44,6 +45,7 @@ import { AdaptiveRateLimitGuard } from './common/guards/adaptive-rate-limit.guar
import { DeprecationInterceptor } from './common/interceptors/deprecation.interceptor';
import { HttpCacheInterceptor } from './common/interceptors/http-cache.interceptor';
import { SandboxModule } from './sandbox/sandbox.module';
import { RedisService as CustomRedisService } from '../cache/redis.service';

@Module({
imports: [
Expand All @@ -54,27 +56,36 @@ import { SandboxModule } from './sandbox/sandbox.module';

BullModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
connection: {
host: configService.get<string>('REDIS_HOST') ?? 'localhost',
port: parseInt(configService.get<string>('REDIS_PORT') ?? '6379', 10),
},
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000,
useFactory: (configService: ConfigService) => {
const isTest = process.env.NODE_ENV === 'test';
return {
connection: {
host: configService.get<string>('REDIS_HOST') ?? 'localhost',
port: parseInt(
configService.get<string>('REDIS_PORT') ?? '6379',
10,
),
maxRetriesPerRequest: isTest ? 0 : null,
enableReadyCheck: !isTest,
retryStrategy: isTest ? () => null : undefined,
},
removeOnComplete: {
age: 3600, // keep for 1 hour
count: 1000,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000,
},
removeOnComplete: {
age: 3600, // keep for 1 hour
count: 1000,
},
removeOnFail: {
age: 24 * 3600, // keep for 24 hours
count: 5000,
},
},
removeOnFail: {
age: 24 * 3600, // keep for 24 hours
count: 5000,
},
},
}),
};
},
inject: [ConfigService],
}),
ScheduleModule.forRoot(),
Expand Down Expand Up @@ -104,14 +115,23 @@ import { SandboxModule } from './sandbox/sandbox.module';
EntityLinkingModule,
DeploymentMetadataModule,
SandboxModule,
DiscoveryModule,
RedisModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
config: {
host: configService.get<string>('REDIS_HOST') ?? 'localhost',
port: parseInt(configService.get<string>('REDIS_PORT') ?? '6379', 10),
},
}),
useFactory: (configService: ConfigService) => {
const isTest = process.env.NODE_ENV === 'test';
return {
config: {
host: configService.get<string>('REDIS_HOST') ?? 'localhost',
port: parseInt(
configService.get<string>('REDIS_PORT') ?? '6379',
10,
),
maxRetriesPerRequest: isTest ? 0 : 3,
retryStrategy: isTest ? () => null : undefined,
},
};
},
inject: [ConfigService],
}),
ThrottlerModule.forRoot([
Expand All @@ -125,6 +145,8 @@ import { SandboxModule } from './sandbox/sandbox.module';
controllers: [AppController],
providers: [
AppService,
CustomRedisService,

{
provide: APP_FILTER,
useClass: AllExceptionsFilter,
Expand All @@ -135,7 +157,11 @@ import { SandboxModule } from './sandbox/sandbox.module';
},
{
provide: APP_GUARD,
useClass: RolesGuard, // runs second — checks request.user.role against @Roles()
useClass: UnexpectedAuthHeaderGuard, // checks for unexpected auth headers on undecorated routes
},
{
provide: APP_GUARD,
useClass: RolesGuard, // runs third — checks request.user.role against @Roles()
},
{
provide: APP_GUARD,
Expand Down
4 changes: 2 additions & 2 deletions app/backend/src/campaigns/campaigns.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe('CampaignsService', () => {
});

const createArgs = prismaMock.campaign.create.mock.calls[0]?.[0];

// Clean match validation instead of strict object equivalence structures
expect(createArgs).toMatchObject({
data: {
Expand Down Expand Up @@ -159,4 +159,4 @@ describe('CampaignsService', () => {
expect(updateArgs?.data).toMatchObject({ deletedAt: expect.any(Date) });
expect(result.deletedAt).not.toBeNull();
});
});
});
7 changes: 6 additions & 1 deletion app/backend/src/campaigns/campaigns.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@ export class CampaignsService {
});
}

async findAll(includeArchived = false, ngoId?: string | null, page = 1, limit = 50) {
async findAll(
includeArchived = false,
ngoId?: string | null,
page = 1,
limit = 50,
) {
const where: Prisma.CampaignWhereInput = {
deletedAt: null,
...(includeArchived ? {} : { archivedAt: null }),
Expand Down
8 changes: 1 addition & 7 deletions app/backend/src/claims/claim-export.controller.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import {
Controller,
Get,
Query,
Res,
Version,
} from '@nestjs/common';
import { Controller, Get, Query, Res, Version } from '@nestjs/common';
import type { Response } from 'express';
import {
ApiTags,
Expand Down
8 changes: 1 addition & 7 deletions app/backend/src/claims/claim-receipt.controller.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import {
Controller,
Get,
Post,
Body,
Param,
} from '@nestjs/common';
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import {
ApiTags,
ApiOperation,
Expand Down
5 changes: 4 additions & 1 deletion app/backend/src/claims/claims.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,7 +748,10 @@
where.OR = [
{
campaign: {
metadata: { path: ['tokenAddress'] as any, equals: query.tokenAddress },
metadata: {
path: ['tokenAddress'] as any,

Check warning on line 752 in app/backend/src/claims/claims.service.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe assignment of an `any` value
equals: query.tokenAddress,
},
},
},
];
Expand Down
1 change: 0 additions & 1 deletion app/backend/src/common/budget/budget.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,3 @@ describe('BudgetService', () => {
);
});
});

4 changes: 4 additions & 0 deletions app/backend/src/common/decorators/no-auth-strict.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';

export const NO_AUTH_STRICT_KEY = 'noAuthStrict';
export const NoAuthStrict = () => SetMetadata(NO_AUTH_STRICT_KEY, true);
Loading
Loading