Skip to content
Merged
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
12 changes: 0 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,3 @@ jobs:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/mergefi
NODE_ENV: test
run: npm run test:e2e
run: |
# The e2e specs build their TestingModule from mocked providers and
# overrideGuard(...)-stubbed auth — none drive a real passport-github2
# handshake, so GITHUB_CLIENT_ID/SECRET were never the right gate
# (#165). What they actually need is a reachable Postgres, which the
# `postgres` service container above always provides.
if [ -z "${DATABASE_URL:-}" ]; then
echo "Skipping E2E tests: DATABASE_URL is not set."
exit 0
fi
npm run test:e2e
shell: bash
16 changes: 16 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,20 @@ export default tseslint.config(
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
{
// Jest mocks (mocked repositories, `jest.fn()` return values, supertest's
// `app.getHttpServer()`) are deliberately loosely typed scaffolding, not
// production logic — the no-unsafe-* / require-await rules exist to catch
// real bugs in application code and produce mostly noise against mocks.
files: ['**/*.spec.ts', 'test/**/*.ts'],
rules: {
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unsafe-function-type': 'off',
'@typescript-eslint/require-await': 'off',
},
},
);
18 changes: 15 additions & 3 deletions src/analytics/analytics.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,29 @@ describe('Analytics SQL aggregation (integration)', () => {
const SEED_SPONSORS = 40;

beforeAll(async () => {
const url =
process.env.DATABASE_URL ??
'postgresql://postgres:postgres@localhost:5432/mergefi';

dataSource = new DataSource({
type: 'postgres',
url:
process.env.DATABASE_URL ??
'postgresql://postgres:postgres@localhost:5432/mergefi',
url,
schema: 'analytics_itest',
entities,
synchronize: true,
dropSchema: true,
});
try {
// TypeORM's dropSchema/synchronize operate within an existing named
// schema — they don't create the schema itself, unlike the default
// `public` schema every Postgres database already has. Ensure
// analytics_itest exists first via a throwaway connection on the
// default schema.
const bootstrap = new DataSource({ type: 'postgres', url });
await bootstrap.initialize();
await bootstrap.query('CREATE SCHEMA IF NOT EXISTS analytics_itest');
await bootstrap.destroy();

await dataSource.initialize();
} catch (err) {
console.warn(
Expand Down
10 changes: 0 additions & 10 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,6 @@ import { ReputationModule } from './reputation/reputation.module';
import { AnalyticsModule } from './analytics/analytics.module';
import { IdempotencyModule } from './common/idempotency/idempotency.module';

// Import the new RolesGuard we created
import { RolesGuard } from './roles.guard';

@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, load: [configuration] }),
Expand Down Expand Up @@ -65,13 +62,6 @@ import { RolesGuard } from './roles.guard';
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
// This registers RolesGuard globally to secure all role permissions across the entire app
// This registers RolesGuard globally to secure all role permissions

{
provide: APP_GUARD,
useClass: RolesGuard,
},
],
})
export class AppModule {}
4 changes: 2 additions & 2 deletions src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export class AuthController {
@Post('handoff')
@HttpCode(200)
@ApiExcludeEndpoint()
async exchangeHandoff(@Body('code') code: string) {
exchangeHandoff(@Body('code') code: string) {
if (!code || typeof code !== 'string') {
throw new UnauthorizedException('Missing handoff code');
}
Expand All @@ -78,7 +78,7 @@ export class AuthController {
@Post('exchange')
@HttpCode(200)
@ApiExcludeEndpoint()
async exchangeHandoffAlias(@Body('code') code: string) {
exchangeHandoffAlias(@Body('code') code: string) {
// Alias for POST /auth/exchange — same single-use semantics as /handoff.
if (!code || typeof code !== 'string') {
throw new UnauthorizedException('Missing handoff code');
Expand Down
14 changes: 11 additions & 3 deletions src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { StringValue } from 'ms';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
Expand All @@ -23,10 +24,17 @@ import { UsersModule } from '../users/users.module';
const jwt = configService.get('jwt', { infer: true });
return {
secret: jwt.secret,
signOptions: {
expiresIn: jwt.expiresIn
signOptions: {
// jwt.expiresIn is a free-form configured string (env var,
// default '7d') — JwtModuleOptions wants jsonwebtoken's
// ms.StringValue template-literal type, which can't be derived
// from a plain `string` at the type level without a runtime
// format check. The configured value is a duration string by
// contract (see config/configuration.ts), so this is a narrow,
// deliberate assertion rather than a blanket `any`.
expiresIn: jwt.expiresIn as StringValue,
},
} as any;
};
},
}),
],
Expand Down
29 changes: 19 additions & 10 deletions src/auth/strategies/github.strategy.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-github2';
import { Strategy, Profile } from 'passport-github2';
import { VerifyCallback } from 'passport-oauth2';
import { ConfigService } from '@nestjs/config';
import { AppConfig } from '../../config/configuration';

@Injectable()
export class GithubStrategy extends PassportStrategy(Strategy, 'github') {
constructor(configService: ConfigService) {
constructor(configService: ConfigService<AppConfig>) {
// We completely override validation layers right here.
// If the config system returns an empty string or undefined,
// If the config system returns an empty string or undefined,
// it automatically uses static string fallbacks so Passport NEVER crashes.
const githubConfig = configService.get('github') || {};
const githubConfig = configService.get('github', { infer: true });

super({
clientID: githubConfig.clientId || 'mock_client_id_12345',
clientSecret: githubConfig.clientSecret || 'mock_secret_key_67890',
callbackURL: githubConfig.oauthCallbackUrl || 'http://localhost:3000/api/auth/github/callback',
clientID: githubConfig?.clientId || 'mock_client_id_12345',
clientSecret: githubConfig?.clientSecret || 'mock_secret_key_67890',
callbackURL:
githubConfig?.oauthCallbackUrl ||
'http://localhost:3000/api/auth/github/callback',
scope: ['user:email', 'read:org'],
});
}

async validate(accessToken: string, refreshToken: string, profile: any, done: any): Promise<any> {
validate(
accessToken: string,
refreshToken: string,
profile: Profile,
done: VerifyCallback,
) {
const { id, username, emails, photos } = profile;
const user = {
githubId: id,
Expand All @@ -29,6 +38,6 @@ export class GithubStrategy extends PassportStrategy(Strategy, 'github') {
accessToken,
refreshToken,
};
return done(null, user);
done(null, user);
}
}
5 changes: 4 additions & 1 deletion src/bounties/bounties.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ export class BountiesController {
list(
@Query('status', new ParseEnumPipe(BountyStatus, { optional: true }))
status?: BountyStatus,
@Query('difficulty', new ParseEnumPipe(BountyDifficulty, { optional: true }))
@Query(
'difficulty',
new ParseEnumPipe(BountyDifficulty, { optional: true }),
)
difficulty?: BountyDifficulty,
@Query('asset', new ParseEnumPipe(AssetType, { optional: true }))
asset?: AssetType,
Expand Down
7 changes: 6 additions & 1 deletion src/bounties/bounties.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Bounty, Team, User } from '../common/entities';
import { IdempotencyKey } from '../common/entities/idempotency-key.entity';
import { BountiesService } from './bounties.service';
import { BountiesController } from './bounties.controller';
import { BountyExpiryScheduler } from './bounty-expiry.scheduler';
Expand All @@ -9,7 +10,11 @@ import { AuthModule } from '../auth/auth.module';

@Module({
imports: [
TypeOrmModule.forFeature([Bounty, Team, User]),
// RolesGuard and @Idempotent()'s IdempotencyInterceptor are used via
// @UseGuards/@UseInterceptors class references on BountiesController —
// see TeamsModule for why each needs its repository resolvable here
// directly rather than only through an imported/global module.
TypeOrmModule.forFeature([Bounty, Team, User, IdempotencyKey]),
EscrowModule,
AuthModule,
],
Expand Down
2 changes: 1 addition & 1 deletion src/bounties/bounties.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ export class BountiesService {
return qb.getMany();
}

approve(id: string) {
approve(id: string) {
return Promise.resolve({ id, status: 'approved' });
}

Expand Down
24 changes: 14 additions & 10 deletions src/common/encryption.transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,20 @@ export class EncryptionTransformer implements ValueTransformer {
to(value: string | null): string | null {
if (!value) return null;
try {
const secretKeyString = process.env.ENCRYPTION_KEY || '64a2f98b7e3c1d5e6f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e';
const secretKeyString =
process.env.ENCRYPTION_KEY ||
'64a2f98b7e3c1d5e6f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e';
const key = Buffer.from(secretKeyString, 'hex');
const iv = randomBytes(12);

const cipher = createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(value, 'utf8', 'hex');
encrypted += cipher.final('hex');

const authTag = cipher.getAuthTag().toString('hex');

return `${iv.toString('hex')}:${authTag}:${encrypted}`;
} catch (error) {
} catch {
return value;
}
}
Expand All @@ -27,19 +29,21 @@ export class EncryptionTransformer implements ValueTransformer {
const [ivHex, authTagHex, encryptedDataHex] = value.split(':');
if (!ivHex || !authTagHex || !encryptedDataHex) return value;

const secretKeyString = process.env.ENCRYPTION_KEY || '64a2f98b7e3c1d5e6f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e';
const secretKeyString =
process.env.ENCRYPTION_KEY ||
'64a2f98b7e3c1d5e6f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e';
const key = Buffer.from(secretKeyString, 'hex');
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');

const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);

let decrypted = decipher.update(encryptedDataHex, 'hex', 'utf8');
decrypted += decipher.final('utf8');

return decrypted;
} catch (error) {
} catch {
return value;
}
}
Expand Down
47 changes: 28 additions & 19 deletions src/common/entities/github-account.entity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
import { User } from './user.entity';

Expand All @@ -7,18 +13,20 @@ const encryptionTransformer = {
to: (value: string | null) => {
if (!value) return null;
try {
const secretKeyString = process.env.ENCRYPTION_KEY || '64a2f98b7e3c1d5e6f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e';
const secretKeyString =
process.env.ENCRYPTION_KEY ||
'64a2f98b7e3c1d5e6f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e';
const key = Buffer.from(secretKeyString, 'hex');
const iv = randomBytes(12);

const cipher = createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(value, 'utf8', 'hex');
encrypted += cipher.final('hex');

const authTag = cipher.getAuthTag().toString('hex');

return `${iv.toString('hex')}:${authTag}:${encrypted}`;
} catch (error) {
} catch {
return value;
}
},
Expand All @@ -29,22 +37,24 @@ const encryptionTransformer = {
const [ivHex, authTagHex, encryptedDataHex] = value.split(':');
if (!ivHex || !authTagHex || !encryptedDataHex) return value;

const secretKeyString = process.env.ENCRYPTION_KEY || '64a2f98b7e3c1d5e6f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e';
const secretKeyString =
process.env.ENCRYPTION_KEY ||
'64a2f98b7e3c1d5e6f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e';
const key = Buffer.from(secretKeyString, 'hex');
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');

const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);

let decrypted = decipher.update(encryptedDataHex, 'hex', 'utf8');
decrypted += decipher.final('utf8');

return decrypted;
} catch (error) {
} catch {
return value;
}
}
},
};

@Entity('github_accounts')
Expand Down Expand Up @@ -72,20 +82,19 @@ export class GithubAccount {
@JoinColumn({ name: 'userId' })
user: User;

@Column({
type: 'varchar',
nullable: true,
@Column({
type: 'varchar',
nullable: true,
select: false,
transformer: encryptionTransformer
transformer: encryptionTransformer,
})
accessToken: string | null;

@Column({
@Column({
type: 'varchar',
nullable: true,
select: false,
transformer: encryptionTransformer

transformer: encryptionTransformer,
})
refreshToken: string | null;
}
4 changes: 3 additions & 1 deletion src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ export default (): AppConfig => ({
github: {
clientId: process.env.GITHUB_CLIENT_ID || 'mock_client_id_12345',
clientSecret: process.env.GITHUB_CLIENT_SECRET || 'mock_secret_key_67890',
oauthCallbackUrl: process.env.GITHUB_OAUTH_CALLBACK_URL || 'http://localhost:3000/api/auth/github/callback',
oauthCallbackUrl:
process.env.GITHUB_OAUTH_CALLBACK_URL ||
'http://localhost:3000/api/auth/github/callback',
apiToken: process.env.GITHUB_API_TOKEN ?? '',
webhookSecret: process.env.GITHUB_WEBHOOK_SECRET ?? '',
},
Expand Down
Loading
Loading