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 @@ -809,6 +809,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
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' };
}
}
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
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 @@ -56,4 +56,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);
76 changes: 76 additions & 0 deletions app/backend/src/common/guards/unexpected-auth-header.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { ROLES_KEY } from '../../auth/roles.decorator';
import { NO_AUTH_STRICT_KEY } from '../decorators/no-auth-strict.decorator';

@Injectable()
export class UnexpectedAuthHeaderGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly configService: ConfigService,
) {}

canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
const authHeader = request.headers['authorization'];

// If there is no authorization header, there's no unexpected credential to reject.
if (!authHeader) {
return true;
}

// Check if the route has roles required (is decorated with @Roles)
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
if (requiredRoles) {
return true;
}

// Check if the route is explicitly designed for public anonymous access (is decorated with @NoAuthStrict)
const isNoAuthStrict = this.reflector.getAllAndOverride<boolean>(
NO_AUTH_STRICT_KEY,
[context.getHandler(), context.getClass()],
);
if (isNoAuthStrict) {
return true;
}

// Check if the route is in the PUBLIC_AUTH_BYPASS list
const bypassEnv =
this.configService.get<string>('PUBLIC_AUTH_BYPASS') ?? '';
const bypassedPaths = bypassEnv
.split(',')
.map(p => p.trim())
.filter(Boolean);

const requestPath = request.path;
const isBypassed = bypassedPaths.some(bpath => {
const cleanBPath = bpath.replace(/^\/+|\/+$/g, '');
const cleanReqPath = requestPath.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) {
return true;
}

// Undecorated route received an unexpected authorization header
throw new UnauthorizedException(
'Unexpected authorization credentials on undecorated route',
);
}
}
Loading
Loading