barsa b206de8dba refactor: enterprise-grade cleanup of BFF and domain packages
Comprehensive refactoring across 70 files (net -298 lines) improving
type safety, error handling, and code organization:

- Replace .passthrough()/.catchall(z.unknown()) with .strip() in all Zod schemas
- Tighten Record<string, unknown> to bounded union types where possible
- Replace throw new Error with domain-specific exceptions (OrderException,
  FulfillmentException, WhmcsOperationException, SalesforceOperationException, etc.)
- Split AuthTokenService (625 lines) into TokenGeneratorService and
  TokenRefreshService with thin orchestrator
- Deduplicate FreebitClientService with shared makeRequest() method
- Add typed interfaces to WHMCS facade, order service, and fulfillment mapper
- Externalize hardcoded config values to ConfigService with env fallbacks
- Consolidate duplicate billing cycle enums into shared billingCycleSchema
- Standardize logger usage (nestjs-pino @Inject(Logger) everywhere)
- Move shared WHMCS number coercion helpers to whmcs-utils/schema.ts
2026-02-24 19:05:30 +09:00

136 lines
4.4 KiB
TypeScript

import { Injectable, Inject, ServiceUnavailableException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Redis } from "ioredis";
import { Logger } from "nestjs-pino";
import type { JWTPayload } from "jose";
import type { AuthTokens } from "@customer-portal/domain/auth";
import type { UserAuth, UserRole } from "@customer-portal/domain/customer";
import { TokenGeneratorService } from "./token-generator.service.js";
import { TokenRefreshService } from "./token-refresh.service.js";
import { TokenRevocationService } from "./token-revocation.service.js";
export interface RefreshTokenPayload extends JWTPayload {
userId: string;
/**
* Refresh token family identifier (stable across rotations).
* Present on newly issued tokens; legacy tokens used `tokenId` for this value.
*/
familyId?: string | undefined;
/**
* Refresh token identifier (unique per token). Used for replay/reuse detection.
* For legacy tokens, this was equal to the family id.
*/
tokenId: string;
deviceId?: string | undefined;
userAgent?: string | undefined;
type: "refresh";
}
export interface DeviceInfo {
deviceId?: string | undefined;
userAgent?: string | undefined;
}
const ERROR_SERVICE_UNAVAILABLE = "Authentication service temporarily unavailable";
/**
* Auth Token Service
*
* Thin orchestrator that delegates to focused services:
* - TokenGeneratorService: token creation
* - TokenRefreshService: refresh + rotation logic
* - TokenRevocationService: token revocation
*
* Preserves the existing public API so consumers don't need changes.
*/
@Injectable()
export class AuthTokenService {
private readonly requireRedisForTokens: boolean;
private readonly maintenanceMode: boolean;
private readonly maintenanceMessage: string;
constructor(
private readonly generator: TokenGeneratorService,
private readonly refreshService: TokenRefreshService,
private readonly revocation: TokenRevocationService,
configService: ConfigService,
@Inject("REDIS_CLIENT") private readonly redis: Redis,
@Inject(Logger) private readonly logger: Logger
) {
this.requireRedisForTokens =
configService.get("AUTH_REQUIRE_REDIS_FOR_TOKENS", "false") === "true";
this.maintenanceMode = configService.get("AUTH_MAINTENANCE_MODE", "false") === "true";
this.maintenanceMessage = configService.get(
"AUTH_MAINTENANCE_MESSAGE",
"Authentication service is temporarily unavailable for maintenance. Please try again later."
);
}
private checkServiceAvailability(): void {
if (this.maintenanceMode) {
this.logger.warn("Authentication service in maintenance mode", {
maintenanceMessage: this.maintenanceMessage,
});
throw new ServiceUnavailableException(this.maintenanceMessage);
}
if (this.requireRedisForTokens && this.redis.status !== "ready") {
this.logger.error("Redis required for token operations but not available", {
redisStatus: this.redis.status,
requireRedisForTokens: this.requireRedisForTokens,
});
throw new ServiceUnavailableException(ERROR_SERVICE_UNAVAILABLE);
}
}
/**
* Generate a new token pair with refresh token rotation
*/
async generateTokenPair(
user: { id: string; email: string; role?: UserRole },
deviceInfo?: DeviceInfo
): Promise<AuthTokens> {
this.checkServiceAvailability();
return this.generator.generateTokenPair(user, deviceInfo);
}
/**
* Refresh access token using refresh token rotation
*/
async refreshTokens(
refreshToken: string,
deviceInfo?: DeviceInfo
): Promise<{ tokens: AuthTokens; user: UserAuth }> {
this.checkServiceAvailability();
return this.refreshService.refreshTokens(refreshToken, deviceInfo);
}
/**
* Revoke a specific refresh token
*/
async revokeRefreshToken(refreshToken: string): Promise<void> {
return this.revocation.revokeRefreshToken(refreshToken);
}
/**
* Get all active refresh token families for a user
*/
async getUserRefreshTokenFamilies(userId: string): Promise<
Array<{
familyId: string;
deviceId?: string | undefined;
userAgent?: string | undefined;
createdAt?: string | undefined;
}>
> {
return this.revocation.getUserRefreshTokenFamilies(userId);
}
/**
* Revoke all refresh tokens for a user
*/
async revokeAllUserTokens(userId: string): Promise<void> {
return this.revocation.revokeAllUserTokens(userId);
}
}