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
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const config: Config = {
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
setupFilesAfterEnv: ["<rootDir>/test/jest.setup.ts"],
};

export default config;
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"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",
"docs": "typedoc",
"ci": "bun run format && bun run lint && bun run test:coverage && bun run docs && bun run build",
"act:ci": "act -W .github/workflows/ci.yaml -P ubuntu-latest=ghcr.io/catthehacker/ubuntu:full-latest --container-architecture linux/amd64"
},
"dependencies": {
Expand All @@ -31,6 +32,7 @@
"@nestjs/microservices": "^11.1.18",
"@nestjs/platform-express": "^11.1.18",
"@nestjs/swagger": "^11.2.6",
"@nestjs/throttler": "^6.5.0",
"bullmq": "^5.73.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
Expand Down Expand Up @@ -79,8 +81,8 @@
"ts-loader": "^9.5.7",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typedoc": "^0.28.18",
"typescript": "^5.9.3",
"typescript-eslint": "^8.58.0",
"typedoc": "^0.28.18"
"typescript-eslint": "^8.58.0"
}
}
15 changes: 15 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AppConfigModule } from "@/common/config/config.module";
import { AppConfigService } from "@/common/config/config.service";
import { LogLevel } from "@/common/constants/log-level";
import { IpBearerThrottlerGuard } from "@/common/throttler/throttler.guard";
import { Environment } from "@/common/utils/environment";
import { GrpcModule } from "@/grpc/grpc.module";
import { JobsModule } from "@/jobs/jobs.module";
Expand All @@ -9,6 +10,8 @@ import { KeyGenerationModule } from "@/tasks/key-generation/key-generation.modul
import { SigningModule } from "@/tasks/signing/signing.module";
import { BullModule } from "@nestjs/bullmq";
import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { ThrottlerModule } from "@nestjs/throttler";
import { LoggerModule, type Params } from "nestjs-pino";
import { type TransportTargetOptions } from "pino";

Expand All @@ -26,6 +29,12 @@ import { type TransportTargetOptions } from "pino";
@Module({
imports: [
AppConfigModule,
ThrottlerModule.forRoot([
{
ttl: 60_000,
limit: 100,
},
]),
Comment on lines 31 to +37
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rate limiting is now enabled globally via ThrottlerModule + APP_GUARD, but there are no tests asserting 429 behavior (e.g., repeated requests from same IP/token). Since the repo already has e2e coverage for auth and endpoints, consider adding an e2e test to ensure throttling works and is keyed as intended.

Copilot uses AI. Check for mistakes.
LoggerModule.forRootAsync({
inject: [AppConfigService],
useFactory: (configService: AppConfigService): Params => {
Expand Down Expand Up @@ -78,6 +87,12 @@ import { type TransportTargetOptions } from "pino";
SigningModule,
JobsModule,
],
providers: [
{
provide: APP_GUARD,
useClass: IpBearerThrottlerGuard,
},
],
})
class AppModule {}

Expand Down
7 changes: 7 additions & 0 deletions src/common/auth/bearer.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type CanActivate,
type ExecutionContext,
Injectable,
Logger,
UnauthorizedException,
} from "@nestjs/common";
import { timingSafeEqual } from "crypto";
Expand All @@ -21,6 +22,8 @@ import { timingSafeEqual } from "crypto";
*/
@Injectable()
class BearerGuard implements CanActivate {
private readonly logger: Logger = new Logger(BearerGuard.name);

constructor(private readonly configService: AppConfigService) {}

/**
Expand All @@ -47,26 +50,30 @@ class BearerGuard implements CanActivate {
] ?? null);

if (!authorizationHeader) {
this.logger.warn("Rejected request: missing Authorization header.");
throw new UnauthorizedException(Message.MISSING_AUTH_HEADER);
}

const [scheme, token]: string[] = authorizationHeader.split(" ");

if (scheme?.toLowerCase() !== AuthScheme.BEARER || !token) {
this.logger.warn("Rejected request: invalid Authorization scheme.");
throw new UnauthorizedException(Message.INVALID_AUTH_SCHEME);
}

const expectedToken: string = this.configService.clientBearerToken;

// Prevent token length from leaking information.
if (token.length !== expectedToken.length) {
this.logger.warn("Rejected request: invalid Bearer token.");
throw new UnauthorizedException(Message.INVALID_BEARER_TOKEN);
}

const tokenBuffer: Buffer = Buffer.from(token);
const expectedBuffer: Buffer = Buffer.from(expectedToken);

if (!timingSafeEqual(tokenBuffer, expectedBuffer)) {
this.logger.warn("Rejected request: invalid Bearer token.");
throw new UnauthorizedException(Message.INVALID_BEARER_TOKEN);
}

Expand Down
37 changes: 37 additions & 0 deletions src/common/throttler/throttler.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { AuthScheme, Header } from "@/common/constants/header";
import { Injectable } from "@nestjs/common";
import { ThrottlerGuard as _ThrottlerGuard } from "@nestjs/throttler";
import type { Request } from "express";

/**
* Rate-limiting guard that keys each throttle bucket on the combination of the
* client IP address and Bearer token.
*
* Using both dimensions means:
*
* - Different IPs with the same token are counted separately (IP-level limit).
* - The same IP using different tokens is also counted separately (token-level
* limit).
*
* If no token is present the key degrades gracefully to `<ip>:` so that
* unauthenticated probing is also throttled.
*/
@Injectable()
class ThrottlerGuard extends _ThrottlerGuard {
/**
* Builds the throttle storage key for the incoming request.
*
* @param {Request} request - The raw Express request object.
* @returns {Promise<string>} A string key of the form `<ip>:<token>`.
*/
protected override async getTracker(request: Request): Promise<string> {
const ip: string = request.ip ?? request.socket?.remoteAddress ?? "";
const authHeader: string = request.headers?.[Header.AUTHORIZATION] ?? "";
const token: string = authHeader.startsWith(AuthScheme.BEARER_PREFIX)
? authHeader.slice(7)
: "";
Comment on lines +29 to +32
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

request.headers[authorization] can be string | string[] | undefined in Express. Assigning it to string and calling .startsWith() can mis-handle array values, and the Bearer prefix check is currently case-sensitive (unlike BearerGuard). Normalize the header value (handle arrays) and perform a case-insensitive scheme check before extracting the token.

Suggested change
const authHeader: string = request.headers?.[Header.AUTHORIZATION] ?? "";
const token: string = authHeader.startsWith(AuthScheme.BEARER_PREFIX)
? authHeader.slice(7)
: "";
const authHeaderValue = request.headers?.[Header.AUTHORIZATION];
const authHeader: string = Array.isArray(authHeaderValue)
? authHeaderValue[0] ?? ""
: authHeaderValue ?? "";
const bearerPrefix: string = AuthScheme.BEARER_PREFIX;
const token: string =
authHeader.slice(0, bearerPrefix.length).toLowerCase() ===
bearerPrefix.toLowerCase()
? authHeader.slice(bearerPrefix.length)
: "";

Copilot uses AI. Check for mistakes.
return `${ip}:${token}`;
}
Comment on lines +27 to +34
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The throttle key currently includes the raw Bearer token. This stores credentials in the throttler backend (typically Redis) and can leak tokens via key inspection/metrics. Prefer hashing the token (e.g., SHA-256) or using a non-reversible fingerprint before building the tracker key.

Copilot uses AI. Check for mistakes.
}

export { ThrottlerGuard as IpBearerThrottlerGuard };
16 changes: 15 additions & 1 deletion src/jobs/jobs.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Controller,
Get,
HttpStatus,
Logger,
Param,
ParseUUIDPipe,
UseGuards,
Expand Down Expand Up @@ -34,6 +35,8 @@ import {
@UseGuards(BearerGuard)
@Controller(Endpoint.JOBS)
class JobsController {
private readonly logger: Logger = new Logger(JobsController.name);

constructor(private readonly jobsService: JobsService) {}

/**
Expand All @@ -51,10 +54,21 @@ class JobsController {
description: "Unauthorized.",
})
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: "Job not found." })
@ApiResponse({
status: 429,
description: "Too Many Requests.",
})
async getJobStatus(
@Param("jobId", new ParseUUIDPipe()) jobId: string,
): Promise<JobStatusResponse> {
return this.jobsService.getJobStatus(jobId);
this.logger.debug(`GET /jobs/${jobId}`);

const result: JobStatusResponse =
await this.jobsService.getJobStatus(jobId);

this.logger.debug(`Job status — ${JSON.stringify(result)}`);
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This debug log serializes and emits the full job status response, which may include completed job results (e.g., signatures) and failure reasons. Consider redacting sensitive fields and/or logging only jobId/status to avoid leaking data and inflating logs.

Suggested change
this.logger.debug(`Job status — ${JSON.stringify(result)}`);
this.logger.debug(
`Job status retrieved — jobId=${jobId} status=${result.status}`,
);

Copilot uses AI. Check for mistakes.

return result;
}
}

Expand Down
18 changes: 12 additions & 6 deletions src/jobs/jobs.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from "@/jobs/jobs.types";
import { QueueName } from "@/queue/queue.constants";
import { InjectQueue } from "@nestjs/bullmq";
import { Injectable, NotFoundException } from "@nestjs/common";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { type Job, type JobState, type Queue } from "bullmq";

/**
Expand All @@ -20,6 +20,8 @@ import { type Job, type JobState, type Queue } from "bullmq";
*/
@Injectable()
class JobsService {
private readonly logger: Logger = new Logger(JobsService.name);

constructor(
@InjectQueue(QueueName.KEY_GENERATION)
private readonly keyGenerationQueue: Queue,
Expand Down Expand Up @@ -74,7 +76,10 @@ class JobsService {
job.finishedOn ?? job.processedOn ?? job.timestamp,
).toISOString();

if (!job.id) throw new Error("Job is missing its identifier.");
if (!job.id) {
this.logger.error("BullMQ returned a job without an identifier.");
throw new Error("Job is missing its identifier.");
}

return {
jobId: job.id,
Expand All @@ -99,10 +104,11 @@ class JobsService {
* @throws {Error} When the return value is not a valid object.
*/
private validateJobResult(value: unknown): JobResult {
if (typeof value !== "object" || value === null) {
throw new Error("Job completed with an invalid result.");
}
return value as JobResult;
if (typeof value === "object" && value !== null) return value as JobResult;
this.logger.error(
`Job completed with an invalid result: ${JSON.stringify(value)}`,
);
throw new Error("Job completed with an invalid result.");
}

/**
Expand Down
12 changes: 12 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ const bootstrap = async (): Promise<void> => {
.build();

SwaggerModule.setup("api", app, SwaggerModule.createDocument(app, config));

const logger: Logger = app.get(Logger);
await app.listen(configService.port);
logger.log(
`Swagger UI: http://localhost:${configService.port}/api`,
"Bootstrap",
);
logger.log(
`Bearer token: ${configService.clientBearerToken}`,
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid logging the configured Bearer token to application logs, even in non-production environments. Logs are often shipped to shared systems and this effectively discloses credentials; prefer omitting it entirely or logging only that a token is configured (or a short fingerprint).

Suggested change
`Bearer token: ${configService.clientBearerToken}`,
`Bearer token configured: ${Boolean(configService.clientBearerToken)}`,

Copilot uses AI. Check for mistakes.
"Bootstrap",
);
return;
}

await app.listen(configService.port);
Expand Down
40 changes: 24 additions & 16 deletions src/metadata/metadata.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { AppConfigService } from "@/common/config/config.service";
import { type Metadata } from "@/metadata/metadata.types";
import { Injectable, OnModuleDestroy } from "@nestjs/common";
import { Injectable, Logger, OnModuleDestroy } from "@nestjs/common";
import Redis from "ioredis";
import { ResultAsync } from "neverthrow";

/**
* Redis key prefix used to namespace metadata entries. Prevents collisions
Expand All @@ -28,6 +29,7 @@ const METADATA_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days.
*/
@Injectable()
class MetadataService implements OnModuleDestroy {
private readonly logger = new Logger(MetadataService.name);
private readonly redis: Redis;

constructor(private readonly configService: AppConfigService) {
Expand All @@ -42,7 +44,7 @@ class MetadataService implements OnModuleDestroy {
});
// Prevent unhandled error events from crashing the process.
this.redis.on("error", (error: Error) => {
console.error("[MetadataService] Redis error:", error.message);
this.logger.error("Redis connection error", error.message);
});
}

Expand Down Expand Up @@ -73,22 +75,28 @@ class MetadataService implements OnModuleDestroy {
*
* @param {string} keyIdentifier - Application-assigned stable key
* identifier.
* @returns {Promise<Metadata | null>} The stored `Metadata`, or `null` if
* not found or expired.
* @returns {ResultAsync<Metadata | null, Error>} The stored `Metadata`, or
* `null` if not found or expired. Errors when the stored value cannot be
* parsed (corrupted entry).
*/
async retrieve(keyIdentifier: string): Promise<Metadata | null> {
retrieve(keyIdentifier: string): ResultAsync<Metadata | null, Error> {
const redisKey: string = `${METADATA_REDIS_PREFIX}:${keyIdentifier}`;
const serialized: string | null = await this.redis.get(redisKey);
if (!serialized) return null;

try {
return JSON.parse(serialized) as Metadata;
} catch {
throw new Error(
`Corrupted metadata for '${keyIdentifier}'. ` +
`Delete and re-run key generation.`,
);
}
return ResultAsync.fromPromise(
this.redis.get(redisKey).then((serialized) => {
if (!serialized) return null;
return JSON.parse(serialized) as Metadata;
}),
(cause) => {
this.logger.error(
`Corrupted metadata for key identifier '${keyIdentifier}' — value cannot be parsed as JSON`,
);
return new Error(
`Corrupted metadata for '${keyIdentifier}'. ` +
`Delete and re-run key generation.`,
{ cause },
);
},
Comment on lines +84 to +98
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error mapping treats any failure from redis.get(...) (including Redis connectivity/timeouts) as "corrupted metadata" and returns an error instructing users to delete/re-run key generation. Consider distinguishing JSON parse failures from Redis I/O errors so operational incidents don’t surface as misleading corruption errors.

Copilot uses AI. Check for mistakes.
);
}

/**
Expand Down
12 changes: 10 additions & 2 deletions src/queue/key-generation.processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
type KeyGenerationJobResult,
} from "@/queue/queue.types";
import { Processor, WorkerHost } from "@nestjs/bullmq";
import { Injectable } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common";
import { type Job } from "bullmq";
import { Result } from "neverthrow";

Expand All @@ -33,6 +33,8 @@ import { Result } from "neverthrow";
lockDuration: JobTimeout.KEY_GENERATION,
})
class KeyGenerationProcessor extends WorkerHost {
private readonly logger: Logger = new Logger(KeyGenerationProcessor.name);

constructor(
private readonly grpcService: GrpcService,
private readonly metadataService: MetadataService,
Expand Down Expand Up @@ -61,7 +63,13 @@ class KeyGenerationProcessor extends WorkerHost {
participants: job.data.participants,
});

if (result.isErr()) throw result.error;
if (result.isErr()) {
this.logger.error(
`gRPC GenerateKey failed for job ${job.id}: ${result.error.message}`,
);
throw result.error;
}

const publicKey: string = Buffer.from(result.value.publicKey).toString(
"hex",
);
Expand Down
Loading
Loading