Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
5d7969d
chore: use Nest Logger for bootstrap logging
engdotme Jun 8, 2026
173fc72
chore: enforce no-floating-promises as an error
engdotme Jun 8, 2026
518f878
chore: enable full TypeScript strict mode
engdotme Jun 8, 2026
dff5722
feat: add rate limiting to API and auth routes
engdotme Jun 8, 2026
dc95758
chore: migrate Prisma seed config to prisma.config.ts
engdotme Jun 8, 2026
31d6aee
test: fix e2e jest config and add first unit specs
engdotme Jun 8, 2026
5c38b06
refactor: create reusable ListPageLayout component for admin list pages
engdotme Jun 9, 2026
9280446
refactor: rewrite api-client on axios with interceptors
engdotme Jun 10, 2026
5092d47
refactor: wrap api-client axios instance in a singleton class
engdotme Jun 10, 2026
7d2042b
feat: migrate auth to httpOnly cookies with server-side route protection
engdotme Jun 10, 2026
57eb5b6
fix: retry 401 requests only once after token refresh
engdotme Jun 10, 2026
7217bfb
fix: only render user-supplied URLs as links when they are http(s)
engdotme Jun 10, 2026
fb36054
refactor: keep registration ip/location out of the localStorage
engdotme Jun 10, 2026
8749f9c
fix: clear user-scoped localStorage on sign-out
engdotme Jun 11, 2026
64d1acf
chore: remove unused ProfileSheet component
engdotme Jun 11, 2026
cd930b4
fix: resolve type errors in list-page-layout and applications page
engdotme Jun 11, 2026
0989a17
refactor: prefix feature component exports with their feature name
engdotme Jun 11, 2026
48bb163
feat: block register/login from outside specific country or via VPN
engdotme Jun 11, 2026
a796eab
feat: redirect blocked sign-in/sign-up to /unavailable
engdotme Jun 11, 2026
83b30fb
feat: redirect blocked sign-in/sign-up to /unavailable
engdotme Jun 11, 2026
9d43163
feat: apply region/VPN restriction to OAuth sign-in too
engdotme Jun 11, 2026
46f54d9
refactor: split cohort detail page into smaller components
engdotme Jun 11, 2026
a5e5ed9
refactor: split notification bell into panel and row component
engdotme Jun 11, 2026
a36606e
refactor: split application detail page into focused components
engdotme Jun 11, 2026
7d12d7f
refactor: extract avatar upload from profile form, collapse social fi…
engdotme Jun 11, 2026
1df563e
fix: prevent X-Forwarded-For spoofing via trust proxy config
engdotme Jun 11, 2026
f7c23ed
feat: enrich SEO metadata across public and private routes
engdotme Jun 11, 2026
6e8c377
fix: clarify and soften video intro guide text
engdotme Jun 12, 2026
5944b3a
refactor: rename colocated client views from _name to name-view
engdotme Jun 12, 2026
0944df2
Merge branch 'main' into refactor/cleanup-codebase
engdotme Jun 18, 2026
be65ae2
fix: add missing regionRestriction mock and update auth controller spec
engdotme Jun 18, 2026
f9f92a5
fix: align test suite with auth cookie refactor and component export …
engdotme Jun 18, 2026
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
15 changes: 15 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ REFRESH_COOKIE_NAME="forgeng_refresh"
# Optional cookie domain (leave unset for localhost).
REFRESH_COOKIE_DOMAIN=

# Name of the cookie holding the access token.
ACCESS_COOKIE_NAME="forgeng_access"

# How long an email verification token stays valid (minutes). Default 1440 (24h).
EMAIL_VERIFY_TTL_MINUTES=1440
# How long a password reset token stays valid (minutes). Default 60 (1h).
Expand Down Expand Up @@ -64,3 +67,15 @@ SMTP_PASS=
# "true" or "1" to use TLS.
SMTP_SECURE=false
EMAIL_FROM="no-reply@forgeng.local"

# --- Reverse proxy / trust ---
# Number of trusted reverse-proxy hops in front of this server (nginx, load
# balancer, etc.). Set to 1 for a single proxy, 2 for two hops, 0 for direct.
# When set, Express reads req.ip from X-Forwarded-For correctly and clients
# cannot spoof their IP by injecting a fake XFF header.
TRUST_PROXY=1

# --- IP reputation / region restriction ---
# IPQualityScore API key, used to detect VPNs/proxies and the visitor's country
# on register/login. Leave unset to skip the check entirely.
IPQUALITYSCORE_API_KEY=
2 changes: 1 addition & 1 deletion backend/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export default tseslint.config(
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
Expand Down
5 changes: 2 additions & 3 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,6 @@
"prisma:studio": "prisma studio",
"db:seed": "ts-node prisma/seed.ts"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
Expand All @@ -38,6 +35,7 @@
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.4.4",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^6.3.1",
"bcryptjs": "^3.0.2",
"class-transformer": "^0.5.1",
Expand Down Expand Up @@ -73,6 +71,7 @@
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/supertest": "^7.0.0",
"dotenv": "^17.4.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
Expand Down
15 changes: 15 additions & 0 deletions backend/prisma.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import path from 'node:path';

// A Prisma config file disables Prisma's automatic .env loading, so load it
// ourselves to keep `DATABASE_URL` (and friends) available to the CLI.
import 'dotenv/config';
import { defineConfig } from 'prisma/config';

// Replaces the deprecated `package.json#prisma` block (removed in Prisma 7).
export default defineConfig({
schema: path.join('prisma', 'schema.prisma'),
migrations: {
// Run after `prisma migrate dev` / `migrate reset` apply migrations.
seed: 'ts-node prisma/seed.ts',
},
});
6 changes: 6 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';

import { AppConfigModule } from '@config';
import { CoreModule } from '@core/core.module';
Expand All @@ -17,6 +19,9 @@ import { UsersModule } from '@modules/users/users.module';
@Module({
imports: [
AppConfigModule,
// Baseline rate limit applied to every route (100 requests/min per IP).
// Sensitive auth routes tighten this further via @Throttle.
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
CoreModule,
AuthModule,
HealthModule,
Expand All @@ -30,5 +35,6 @@ import { UsersModule } from '@modules/users/users.module';
DashboardModule,
NotificationsModule,
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule {}
57 changes: 57 additions & 0 deletions backend/src/common/mappers/mappers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { User } from '@prisma/client';

import { toUserDto } from './index';

const makeUser = (overrides: Partial<User> = {}): User =>
({
id: 1,
clerkId: null,
email: 'ada@example.com',
emailVerified: true,
name: 'Ada',
role: 'student',
bio: null,
githubUrl: null,
avatarUrl: null,
registrationIp: null,
registrationCountry: null,
registrationCity: null,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
...overrides,
}) as unknown as User;

describe('toUserDto', () => {
it('serializes createdAt to an ISO string', () => {
const dto = toUserDto(makeUser());
expect(dto.createdAt).toBe('2026-01-01T00:00:00.000Z');
});

it('copies scalar fields straight through', () => {
const dto = toUserDto(
makeUser({ email: 'grace@example.com', name: 'Grace' }),
);
expect(dto.email).toBe('grace@example.com');
expect(dto.name).toBe('Grace');
});

it('defaults social links to null when no application socials are given', () => {
const dto = toUserDto(makeUser());
expect(dto.linkedin).toBeNull();
expect(dto.github).toBeNull();
expect(dto.whatsapp).toBeNull();
});

it('pulls social links from the provided application socials', () => {
const dto = toUserDto(makeUser(), {
linkedin: 'https://linkedin.com/in/ada',
twitter: null,
facebook: null,
github: 'https://github.com/ada',
portfolio: null,
telegram: null,
whatsapp: null,
});
expect(dto.linkedin).toBe('https://linkedin.com/in/ada');
expect(dto.github).toBe('https://github.com/ada');
});
});
10 changes: 10 additions & 0 deletions backend/src/config/config.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface AppConfiguration {
refreshTtl: string;
refreshCookieName: string;
refreshCookieDomain: string | undefined;
accessCookieName: string;
verifyTokenTtlMinutes: number;
passwordResetTtlMinutes: number;
oauthSuccessRedirect: string;
Expand All @@ -28,6 +29,15 @@ export interface AppConfiguration {
};
smtp: SmtpConfig | null;
emailFrom: string;
ipQualityScore: {
apiKey: string | undefined;
};
/**
* Number of trusted reverse-proxy hops in front of the server.
* Set to 1 when behind a single nginx/load-balancer, 2 for two hops, etc.
* 0 = no proxy (default, req.ip is the socket IP).
*/
trustProxy: number;
}

export interface OAuthProviderConfig {
Expand Down
6 changes: 6 additions & 0 deletions backend/src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const DEFAULT_FRONTEND = 'http://localhost:3000';
const DEFAULT_ACCESS_TTL = '15m';
const DEFAULT_REFRESH_TTL = '7d';
const DEFAULT_REFRESH_COOKIE = 'forgeng_refresh';
const DEFAULT_ACCESS_COOKIE = 'forgeng_access';
const DEFAULT_VERIFY_TTL_MINUTES = 60 * 24; // 24h
const DEFAULT_PASSWORD_RESET_TTL_MINUTES = 60; // 1h — reset links are short-lived
const DEFAULT_EMAIL_FROM = 'no-reply@forgeng.local';
Expand Down Expand Up @@ -93,6 +94,7 @@ export default (): AppConfiguration => {
refreshCookieName:
process.env.REFRESH_COOKIE_NAME ?? DEFAULT_REFRESH_COOKIE,
refreshCookieDomain: process.env.REFRESH_COOKIE_DOMAIN,
accessCookieName: process.env.ACCESS_COOKIE_NAME ?? DEFAULT_ACCESS_COOKIE,
verifyTokenTtlMinutes: parsePort(
process.env.EMAIL_VERIFY_TTL_MINUTES,
DEFAULT_VERIFY_TTL_MINUTES,
Expand Down Expand Up @@ -126,5 +128,9 @@ export default (): AppConfiguration => {
},
smtp: parseSmtp(),
emailFrom: process.env.EMAIL_FROM ?? DEFAULT_EMAIL_FROM,
ipQualityScore: {
apiKey: process.env.IPQUALITYSCORE_API_KEY,
},
trustProxy: parsePort(process.env.TRUST_PROXY, 0),
};
};
13 changes: 13 additions & 0 deletions backend/src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ class EnvironmentVariables {
@IsString()
REFRESH_COOKIE_DOMAIN?: string;

@IsOptional()
@IsString()
ACCESS_COOKIE_NAME?: string;

@IsOptional()
@IsInt()
EMAIL_VERIFY_TTL_MINUTES?: number;
Expand Down Expand Up @@ -138,6 +142,15 @@ class EnvironmentVariables {
@IsOptional()
@IsString()
EMAIL_FROM?: string;

@IsOptional()
@IsString()
IPQUALITYSCORE_API_KEY?: string;

@IsOptional()
@IsInt()
@Min(0)
TRUST_PROXY?: number;
}

/** Validates `process.env` at startup; fails fast on missing required vars. */
Expand Down
14 changes: 11 additions & 3 deletions backend/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'tsconfig-paths/register';

import { ValidationPipe } from '@nestjs/common';
import { Logger, ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
Expand All @@ -22,6 +22,12 @@ async function bootstrap(): Promise<void> {
const corsOrigin = config.getOrThrow('corsOrigin', { infer: true });
const uploadsDir = config.getOrThrow('uploadsDir', { infer: true });

// Tell Express how many trusted reverse-proxy hops sit in front of this
// server. When set, req.ip is derived from X-Forwarded-For correctly and
// clients cannot spoof it by injecting a fake XFF header value.
const trustProxy = config.getOrThrow('trustProxy', { infer: true });
if (trustProxy > 0) app.set('trust proxy', trustProxy);

app.setGlobalPrefix('api');

// Serve uploaded files (avatars, etc.). The global `/api` prefix only applies
Expand Down Expand Up @@ -54,9 +60,11 @@ async function bootstrap(): Promise<void> {
setupSwagger(app);

await app.listen(port);
console.log(`🚀 forgeng API running on http://localhost:${port}/api`);

const logger = new Logger('Bootstrap');
logger.log(`🚀 forgeng API running on http://localhost:${port}/api`);
if (config.getOrThrow('nodeEnv', { infer: true }) !== 'production') {
console.log(`📖 OpenAPI docs at http://localhost:${port}/api/docs`);
logger.log(`📖 OpenAPI docs at http://localhost:${port}/api/docs`);
}
}
void bootstrap();
7 changes: 7 additions & 0 deletions backend/src/modules/auth/auth.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,10 @@ export const PASSWORD_MAX_LENGTH = 72;
/** At least one letter and one digit. */
export const PASSWORD_PATTERN = /^(?=.*[A-Za-z])(?=.*\d).+$/;
export const PASSWORD_MESSAGE = 'Password must contain a letter and a digit.';

/**
* Tighter rate limit for sensitive unauthenticated auth endpoints — login,
* register, password reset, and verification — to curb brute-force and email
* abuse: 5 requests per minute per IP (vs. the global 100/min default).
*/
export const AUTH_THROTTLE = { default: { limit: 5, ttl: 60_000 } };
Loading
Loading