wip
This commit is contained in:
@@ -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>>;
|
||||
}
|
||||
21
packages/application/ports/ICheckoutConfirmationPort.ts
Normal file
21
packages/application/ports/ICheckoutConfirmationPort.ts
Normal 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>>;
|
||||
}
|
||||
14
packages/application/ports/ICheckoutService.ts
Normal file
14
packages/application/ports/ICheckoutService.ts
Normal 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>>;
|
||||
}
|
||||
3
packages/application/ports/IUserConfirmationPort.ts
Normal file
3
packages/application/ports/IUserConfirmationPort.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface IUserConfirmationPort {
|
||||
confirm(message: string): Promise<boolean>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
65
packages/application/use-cases/ConfirmCheckoutUseCase.ts
Normal file
65
packages/application/use-cases/ConfirmCheckoutUseCase.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user