Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9b05dc3
feat: add TypeScript interfaces for JWT authentication
GitAddRemote Apr 12, 2026
ae6a780
refactor: update auth module to use typed interfaces
GitAddRemote Apr 12, 2026
2354545
refactor: replace any types in controllers with AuthenticatedRequest
GitAddRemote Apr 12, 2026
3fdfd47
refactor: implement typed DTOs for UEX repository update operations
GitAddRemote Apr 12, 2026
1c29125
refactor: improve error handling with proper type narrowing
GitAddRemote Apr 12, 2026
0a6a1d3
refactor: use Record<string, unknown> for audit log metadata
GitAddRemote Apr 12, 2026
361eb4b
feat: add scheduled refresh token cleanup job
GitAddRemote Apr 21, 2026
5c17161
refactor: eliminate TypeScript any types across backend and frontend
GitAddRemote Apr 21, 2026
213d2f9
refactor: address PR review items for ISSUE-100
GitAddRemote Apr 21, 2026
11f4e76
chore: merge main into feat/ISSUE-100-eliminate-typescript-any
GitAddRemote Apr 21, 2026
ac317c2
refactor: address remaining PR #122 review items
GitAddRemote Apr 21, 2026
142361a
refactor: eliminate explicit any types from spec files
GitAddRemote Apr 21, 2026
eaeaa2b
fix: resolve TypeScript compile errors in spec files after any elimin…
GitAddRemote Apr 21, 2026
dcb5384
refactor: address final code review items on PR #122
GitAddRemote Apr 21, 2026
db26cfe
refactor: address code review items from latest PR #122 review round
GitAddRemote Apr 21, 2026
8f3cd6d
fix: restore userId field name in getProfile response
GitAddRemote Apr 21, 2026
4ce2a06
fix: address password leak and query param type narrowness
GitAddRemote Apr 21, 2026
8a722fe
fix: correct me endpoint field name and audit log plain-object guard
GitAddRemote Apr 21, 2026
baa8bc3
refactor: eliminate unknown cast in BaseUexRepository update methods
GitAddRemote Apr 22, 2026
a2dbc4b
fix: validate numeric query params in UserInventoryController.list()
GitAddRemote Apr 22, 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
1 change: 1 addition & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@ UEX_BACKOFF_BASE_MS=1000
UEX_RATE_LIMIT_PAUSE_MS=2000
UEX_ENDPOINTS_PAUSE_MS=2000
UEX_API_KEY=

2 changes: 1 addition & 1 deletion backend/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@ module.exports = {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-explicit-any': 'error',
},
};
36 changes: 18 additions & 18 deletions backend/data-source.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
exports.AppDataSource = void 0;
require("dotenv/config");
const typeorm_1 = require("typeorm");
const user_entity_1 = require("./src/modules/users/user.entity");
require('dotenv/config');
const typeorm_1 = require('typeorm');
const user_entity_1 = require('./src/modules/users/user.entity');
exports.AppDataSource = new typeorm_1.DataSource({
type: 'postgres',
host: process.env.DATABASE_HOST,
port: parseInt(process.env.DATABASE_PORT || '0'),
username: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME,
entities: [user_entity_1.User],
migrations: ['src/migrations/*.ts'],
synchronize: false,
type: 'postgres',
host: process.env.DATABASE_HOST,
port: parseInt(process.env.DATABASE_PORT || '0'),
username: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME,
entities: [user_entity_1.User],
migrations: ['src/migrations/*.ts'],
synchronize: false,
});
exports.AppDataSource.initialize()
.then(() => {
.then(() => {
console.log('Data Source has been initialized!');
})
.catch((err) => {
})
.catch((err) => {
console.error('Error during Data Source initialization:', err);
});
});
28 changes: 17 additions & 11 deletions backend/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const compat = new FlatCompat({
module.exports = [
...compat.extends(
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended'
'plugin:prettier/recommended',
),
{
files: ['src/**/*.ts'],
Expand All @@ -26,11 +26,14 @@ module.exports = [
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
}],
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
},
},
{
Expand All @@ -45,11 +48,14 @@ module.exports = [
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
}],
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
},
},
];
6 changes: 4 additions & 2 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,12 @@ if (!isTest) {
});
console.log('✅ Redis cache connected successfully');
return { store };
} catch (error: any) {
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.warn(
'⚠️ Redis connection failed, using in-memory cache:',
error?.message || error,
errorMessage,
);
// Fall back to in-memory cache if Redis is not available
return {
Expand Down
31 changes: 22 additions & 9 deletions backend/src/common/filters/http-exception.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import {
} from '@nestjs/common';
import { Request, Response } from 'express';

interface HttpExceptionResponse {
message?: string | string[];
errors?: unknown;
[key: string]: unknown;
}

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
Expand All @@ -16,21 +22,28 @@ export class HttpExceptionFilter implements ExceptionFilter {
const status = exception.getStatus();
const exceptionResponse = exception.getResponse();

let message: string | string[] = 'An error occurred';
let errors: unknown = undefined;

if (typeof exceptionResponse === 'string') {
message = exceptionResponse;
} else if (
typeof exceptionResponse === 'object' &&
exceptionResponse !== null
) {
const typedResponse = exceptionResponse as HttpExceptionResponse;
message = typedResponse.message || 'An error occurred';
errors = typedResponse.errors;
}

const errorResponse = {
success: false,
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
message:
typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as any).message || 'An error occurred',
errors:
typeof exceptionResponse === 'object' &&
(exceptionResponse as any).errors
? (exceptionResponse as any).errors
: undefined,
message,
errors,
};

response.status(status).json(errorResponse);
Expand Down
4 changes: 2 additions & 2 deletions backend/src/common/guards/throttler.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import { Request } from 'express';
*/
@Injectable()
export class CustomThrottlerGuard extends ThrottlerGuard {
protected async getTracker(req: Record<string, any>): Promise<string> {
const request = req as Request;
protected async getTracker(req: Record<string, unknown>): Promise<string> {
const request = req as unknown as Request;
// req.ips is populated by Express only when trust proxy is configured;
// it contains the full chain of forwarded IPs with spoofed entries stripped.
// Fall back to req.ip (the direct connection address) when not behind a proxy.
Expand Down
89 changes: 68 additions & 21 deletions backend/src/common/interceptors/audit-log.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,32 @@ import {
AuditLogMetadata,
} from '../decorators/audit-log.decorator';

interface AuditLogResponse {
id?: number | string;
[key: string]: unknown;
}

interface AuditLogRequest {
user?: {
userId?: number;
username?: string;
};
params?: Record<string, string>;
query?: Record<string, unknown>;
method: string;
url: string;
ip: string;
headers: Record<string, string | string[] | undefined>;
}

@Injectable()
export class AuditLogInterceptor implements NestInterceptor {
constructor(
private reflector: Reflector,
private auditLogsService: AuditLogsService,
) {}

intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const auditMetadata = this.reflector.getAllAndOverride<AuditLogMetadata>(
AUDIT_LOG_KEY,
[context.getHandler(), context.getClass()],
Expand All @@ -30,35 +48,64 @@ export class AuditLogInterceptor implements NestInterceptor {
return next.handle();
}

const request = context.switchToHttp().getRequest();
const request = context.switchToHttp().getRequest() as AuditLogRequest;
const user = request.user;
const { action, entityType } = auditMetadata;

return next.handle().pipe(
tap(async (response) => {
tap((response: unknown) => {
const isPlainObject =
typeof response === 'object' &&
response !== null &&
!Array.isArray(response);
const typedResponse = isPlainObject
? (response as AuditLogResponse)
: undefined;

Comment thread
GitAddRemote marked this conversation as resolved.
// Extract entity ID from response or params
const entityId =
response?.id ||
let entityId: number | undefined;
const rawEntityId =
typedResponse?.id ||
request.params?.id ||
request.params?.organizationId ||
request.params?.roleId;

await this.auditLogsService.log({
userId: user?.userId,
username: user?.username,
action,
entityType,
entityId: entityId ? parseInt(entityId, 10) : undefined,
metadata: {
method: request.method,
url: request.url,
params: request.params,
query: request.query,
},
newValues: response,
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
});
if (rawEntityId !== undefined) {
if (typeof rawEntityId === 'number') {
entityId = Number.isNaN(rawEntityId) ? undefined : rawEntityId;
} else if (
typeof rawEntityId === 'string' &&
/^\d+$/.test(rawEntityId)
) {
entityId = parseInt(rawEntityId, 10);
}
// Non-numeric IDs (e.g. UUIDs) are intentionally left as undefined
// to avoid silent truncation like parseInt('1e3...') → 1.
}

this.auditLogsService
.log({
userId: user?.userId,
username: user?.username,
action,
entityType,
entityId,
metadata: {
method: request.method,
url: request.url,
params: request.params,
query: request.query,
},
newValues: typedResponse,
ipAddress: request.ip,
Comment thread
GitAddRemote marked this conversation as resolved.
userAgent:
typeof request.headers['user-agent'] === 'string'
? request.headers['user-agent']
: undefined,
})
.catch(() => {
// Audit log failures must not affect the response pipeline
});
}),
);
}
Expand Down
8 changes: 8 additions & 0 deletions backend/src/common/types/query-params.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface QueryParams {
[key: string]: string | string[] | QueryParams | QueryParams[] | undefined;
}

/** Narrows a query param value to string | undefined, ignoring arrays/objects. */
export function asString(value: QueryParams[string]): string | undefined {
return typeof value === 'string' ? value : undefined;
}
50 changes: 34 additions & 16 deletions backend/src/database/seeds/database-seeder.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,49 +155,67 @@ describe('DatabaseSeederService', () => {
.spyOn(gamesRepository, 'findOne')
.mockResolvedValueOnce(null) // First game check (sc)
.mockResolvedValueOnce(null) // Second game check (sq42)
.mockResolvedValueOnce(mockGame as any) // Get sc game for org creation
.mockResolvedValueOnce(mockGame as unknown as Game) // Get sc game for org creation
.mockResolvedValueOnce(null); // Org check

jest.spyOn(gamesRepository, 'create').mockReturnValue(mockGame as any);
jest.spyOn(gamesRepository, 'save').mockResolvedValue(mockGame as any);
jest
.spyOn(gamesRepository, 'create')
.mockReturnValue(mockGame as unknown as Game);
jest
.spyOn(gamesRepository, 'save')
.mockResolvedValue(mockGame as unknown as Game);

jest.spyOn(rolesRepository, 'findOne').mockResolvedValue(null);
jest.spyOn(rolesRepository, 'create').mockReturnValue(mockRole as any);
jest.spyOn(rolesRepository, 'save').mockResolvedValue(mockRole as any);
jest
.spyOn(rolesRepository, 'create')
.mockReturnValue(mockRole as unknown as Role);
jest
.spyOn(rolesRepository, 'save')
.mockResolvedValue(mockRole as unknown as Role);

jest.spyOn(organizationsRepository, 'findOne').mockResolvedValue(null);
jest
.spyOn(organizationsRepository, 'create')
.mockReturnValue(mockOrganization as any);
.mockReturnValue(mockOrganization as unknown as Organization);
jest
.spyOn(organizationsRepository, 'save')
.mockResolvedValue(mockOrganization as any);
.mockResolvedValue(mockOrganization as unknown as Organization);

jest.spyOn(usersRepository, 'findOne').mockResolvedValue(null);
jest.spyOn(usersRepository, 'create').mockReturnValue(mockUser as any);
jest.spyOn(usersRepository, 'save').mockResolvedValue(mockUser as any);
jest
.spyOn(usersRepository, 'create')
.mockReturnValue(mockUser as unknown as User);
jest
.spyOn(usersRepository, 'save')
.mockResolvedValue(mockUser as unknown as User);

jest.spyOn(userOrgRolesRepository, 'findOne').mockResolvedValue(null);
jest
.spyOn(userOrgRolesRepository, 'create')
.mockReturnValue(mockUserOrgRole as any);
.mockReturnValue(mockUserOrgRole as unknown as UserOrganizationRole);
jest
.spyOn(userOrgRolesRepository, 'save')
.mockResolvedValue(mockUserOrgRole as any);
.mockResolvedValue(mockUserOrgRole as unknown as UserOrganizationRole);

await expect(service.seedAll()).resolves.toBeUndefined();
});

it('should handle existing data gracefully', async () => {
jest.spyOn(gamesRepository, 'findOne').mockResolvedValue(mockGame as any);
jest.spyOn(rolesRepository, 'findOne').mockResolvedValue(mockRole as any);
jest
.spyOn(gamesRepository, 'findOne')
.mockResolvedValue(mockGame as unknown as Game);
jest
.spyOn(rolesRepository, 'findOne')
.mockResolvedValue(mockRole as unknown as Role);
jest
.spyOn(organizationsRepository, 'findOne')
.mockResolvedValue(mockOrganization as any);
jest.spyOn(usersRepository, 'findOne').mockResolvedValue(mockUser as any);
.mockResolvedValue(mockOrganization as unknown as Organization);
jest
.spyOn(usersRepository, 'findOne')
.mockResolvedValue(mockUser as unknown as User);
jest
.spyOn(userOrgRolesRepository, 'findOne')
.mockResolvedValue(mockUserOrgRole as any);
.mockResolvedValue(mockUserOrgRole as unknown as UserOrganizationRole);

await expect(service.seedAll()).resolves.toBeUndefined();

Expand Down
Loading
Loading