This commit is contained in:
2025-11-26 17:03:29 +01:00
parent ff3528e5ef
commit fef75008d8
147 changed files with 112370 additions and 5162 deletions

View File

@@ -1,9 +1,10 @@
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
import { BrowserAuthenticationState } from '../../domain/value-objects/BrowserAuthenticationState';
import { Result } from '../../shared/result/Result';
/**
* Port for authentication services implementing zero-knowledge login.
*
*
* GridPilot never sees, stores, or transmits user credentials.
* Authentication is handled by opening a visible browser window where
* the user logs in directly with iRacing. GridPilot only observes
@@ -13,7 +14,7 @@ export interface IAuthenticationService {
/**
* Check if user has a valid session without prompting login.
* Navigates to a protected iRacing page and checks for login redirects.
*
*
* @returns Result containing the current authentication state
*/
checkSession(): Promise<Result<AuthenticationState>>;
@@ -22,7 +23,7 @@ export interface IAuthenticationService {
* Open browser for user to login manually.
* The browser window is visible so user can verify they're on the real iRacing site.
* GridPilot waits for URL change indicating successful login.
*
*
* @returns Result indicating success (login complete) or failure (cancelled/timeout)
*/
initiateLogin(): Promise<Result<void>>;
@@ -30,7 +31,7 @@ export interface IAuthenticationService {
/**
* Clear the persistent session (logout).
* Removes stored browser context and cookies.
*
*
* @returns Result indicating success or failure
*/
clearSession(): Promise<Result<void>>;
@@ -38,8 +39,38 @@ export interface IAuthenticationService {
/**
* Get current authentication state.
* Returns cached state without making network requests.
*
*
* @returns The current AuthenticationState
*/
getState(): AuthenticationState;
/**
* Validate session with server-side check.
* Makes a lightweight HTTP request to verify cookies are still valid on the server.
*
* @returns Result containing true if server confirms validity, false otherwise
*/
validateServerSide(): Promise<Result<boolean>>;
/**
* Refresh session state from cookie store.
* Re-reads cookies and updates internal state without server validation.
*
* @returns Result indicating success or failure
*/
refreshSession(): Promise<Result<void>>;
/**
* Get session expiry date.
* Returns the expiry time extracted from session cookies.
*
* @returns Result containing the expiry Date or null if no expiration
*/
getSessionExpiry(): Promise<Result<Date | null>>;
/**
* Verify browser page shows authenticated state.
* Checks page content for authentication indicators.
*/
verifyPageAuthentication(): Promise<Result<BrowserAuthenticationState>>;
}

View File

@@ -0,0 +1,21 @@
import { Result } from '../../shared/result/Result';
import { CheckoutConfirmation } from '../../domain/value-objects/CheckoutConfirmation';
import { CheckoutPrice } from '../../domain/value-objects/CheckoutPrice';
import { CheckoutState } from '../../domain/value-objects/CheckoutState';
export interface CheckoutConfirmationRequest {
price: CheckoutPrice;
state: CheckoutState;
sessionMetadata: {
sessionName: string;
trackId: string;
carIds: string[];
};
timeoutMs: number;
}
export interface ICheckoutConfirmationPort {
requestCheckoutConfirmation(
request: CheckoutConfirmationRequest
): Promise<Result<CheckoutConfirmation>>;
}

View File

@@ -0,0 +1,14 @@
import { Result } from '../../shared/result/Result';
import { CheckoutPrice } from '../../domain/value-objects/CheckoutPrice';
import { CheckoutState } from '../../domain/value-objects/CheckoutState';
export interface CheckoutInfo {
price: CheckoutPrice | null;
state: CheckoutState;
buttonHtml: string;
}
export interface ICheckoutService {
extractCheckoutInfo(): Promise<Result<CheckoutInfo>>;
proceedWithCheckout(): Promise<Result<void>>;
}

View File

@@ -0,0 +1,3 @@
export interface IUserConfirmationPort {
confirm(message: string): Promise<boolean>;
}

View File

@@ -1,22 +1,98 @@
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
import { Result } from '../../shared/result/Result';
import type { IAuthenticationService } from '../ports/IAuthenticationService';
import { SessionLifetime } from '../../domain/value-objects/SessionLifetime';
/**
* Port for optional server-side session validation.
*/
export interface ISessionValidator {
validateSession(): Promise<Result<boolean>>;
}
/**
* Use case for checking if the user has a valid iRacing session.
*
*
* This validates the session before automation starts, allowing
* the system to prompt for re-authentication if needed.
*
* Implements hybrid validation strategy:
* - File-based validation (fast, always executed)
* - Optional server-side validation (slow, requires network)
*/
export class CheckAuthenticationUseCase {
constructor(private readonly authService: IAuthenticationService) {}
constructor(
private readonly authService: IAuthenticationService,
private readonly sessionValidator?: ISessionValidator
) {}
/**
* Execute the authentication check.
*
*
* @param options Optional configuration for validation
* @returns Result containing the current AuthenticationState
*/
async execute(): Promise<Result<AuthenticationState>> {
return this.authService.checkSession();
async execute(options?: {
requireServerValidation?: boolean;
verifyPageContent?: boolean;
}): Promise<Result<AuthenticationState>> {
// Step 1: File-based validation (fast)
const fileResult = await this.authService.checkSession();
if (fileResult.isErr()) {
return fileResult;
}
const fileState = fileResult.unwrap();
// Step 2: Check session expiry if authenticated
if (fileState === AuthenticationState.AUTHENTICATED) {
const expiryResult = await this.authService.getSessionExpiry();
if (expiryResult.isErr()) {
// Don't fail completely if we can't get expiry, use file-based state
return Result.ok(fileState);
}
const expiry = expiryResult.unwrap();
if (expiry !== null) {
try {
const sessionLifetime = new SessionLifetime(expiry);
if (sessionLifetime.isExpired()) {
return Result.ok(AuthenticationState.EXPIRED);
}
} catch {
// Invalid expiry date, treat as expired for safety
return Result.ok(AuthenticationState.EXPIRED);
}
}
}
// Step 3: Optional page content verification
if (options?.verifyPageContent && fileState === AuthenticationState.AUTHENTICATED) {
const pageResult = await this.authService.verifyPageAuthentication();
if (pageResult.isOk()) {
const browserState = pageResult.unwrap();
// If cookies valid but page shows login UI, session is expired
if (!browserState.isFullyAuthenticated()) {
return Result.ok(AuthenticationState.EXPIRED);
}
}
// Don't block on page verification errors, continue with file-based state
}
// Step 4: Optional server-side validation
if (this.sessionValidator && fileState === AuthenticationState.AUTHENTICATED) {
const serverResult = await this.sessionValidator.validateSession();
// Don't block on server validation errors
if (serverResult.isOk()) {
const isValid = serverResult.unwrap();
if (!isValid) {
return Result.ok(AuthenticationState.EXPIRED);
}
}
}
return Result.ok(fileState);
}
}

View File

@@ -0,0 +1,38 @@
import { Result } from '../../shared/result/Result';
import { RaceCreationResult } from '../../domain/value-objects/RaceCreationResult';
import type { ICheckoutService } from '../ports/ICheckoutService';
export class CompleteRaceCreationUseCase {
constructor(private readonly checkoutService: ICheckoutService) {}
async execute(sessionId: string): Promise<Result<RaceCreationResult>> {
if (!sessionId || sessionId.trim() === '') {
return Result.err(new Error('Session ID is required'));
}
const infoResult = await this.checkoutService.extractCheckoutInfo();
if (infoResult.isErr()) {
return Result.err(infoResult.unwrapErr());
}
const info = infoResult.unwrap();
if (!info.price) {
return Result.err(new Error('Could not extract price from checkout page'));
}
try {
const raceCreationResult = RaceCreationResult.create({
sessionId,
price: info.price.toDisplayString(),
timestamp: new Date(),
});
return Result.ok(raceCreationResult);
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error');
return Result.err(err);
}
}
}

View File

@@ -0,0 +1,65 @@
import { Result } from '../../shared/result/Result';
import { ICheckoutService } from '../ports/ICheckoutService';
import { ICheckoutConfirmationPort } from '../ports/ICheckoutConfirmationPort';
import { CheckoutStateEnum } from '../../domain/value-objects/CheckoutState';
interface SessionMetadata {
sessionName: string;
trackId: string;
carIds: string[];
}
export class ConfirmCheckoutUseCase {
private static readonly DEFAULT_TIMEOUT_MS = 30000;
constructor(
private readonly checkoutService: ICheckoutService,
private readonly confirmationPort: ICheckoutConfirmationPort
) {}
async execute(sessionMetadata?: SessionMetadata): Promise<Result<void>> {
const infoResult = await this.checkoutService.extractCheckoutInfo();
if (infoResult.isErr()) {
return Result.err(infoResult.unwrapErr());
}
const info = infoResult.unwrap();
if (info.state.getValue() === CheckoutStateEnum.INSUFFICIENT_FUNDS) {
return Result.err(new Error('Insufficient funds to complete checkout'));
}
if (!info.price) {
return Result.err(new Error('Could not extract price from checkout page'));
}
// Request confirmation via port with full checkout context
const confirmationResult = await this.confirmationPort.requestCheckoutConfirmation({
price: info.price,
state: info.state,
sessionMetadata: sessionMetadata || {
sessionName: 'Unknown Session',
trackId: 'unknown',
carIds: [],
},
timeoutMs: ConfirmCheckoutUseCase.DEFAULT_TIMEOUT_MS,
});
if (confirmationResult.isErr()) {
return Result.err(confirmationResult.unwrapErr());
}
const confirmation = confirmationResult.unwrap();
if (confirmation.isCancelled()) {
return Result.err(new Error('Checkout cancelled by user'));
}
if (confirmation.isTimeout()) {
return Result.err(new Error('Checkout confirmation timeout'));
}
return await this.checkoutService.proceedWithCheckout();
}
}

View File

@@ -0,0 +1,37 @@
import { IAuthenticationService } from '../ports/IAuthenticationService';
import { Result } from '../../shared/result/Result';
import { BrowserAuthenticationState } from '../../domain/value-objects/BrowserAuthenticationState';
/**
* Use case for verifying browser shows authenticated page state.
* Combines cookie validation with page content verification.
*/
export class VerifyAuthenticatedPageUseCase {
constructor(
private readonly authService: IAuthenticationService
) {}
async execute(): Promise<Result<BrowserAuthenticationState>> {
try {
const result = await this.authService.verifyPageAuthentication();
if (result.isErr()) {
return Result.err(result.error);
}
const browserState = result.unwrap();
// Log verification result
if (browserState.isFullyAuthenticated()) {
// Success case - no logging needed in use case
} else if (browserState.requiresReauthentication()) {
// Requires re-auth - caller should handle
}
return Result.ok(browserState);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return Result.err(new Error(`Page verification failed: ${message}`));
}
}
}