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

@@ -0,0 +1,39 @@
import { AuthenticationState } from './AuthenticationState';
export class BrowserAuthenticationState {
private readonly cookiesValid: boolean;
private readonly pageAuthenticated: boolean;
constructor(cookiesValid: boolean, pageAuthenticated: boolean) {
this.cookiesValid = cookiesValid;
this.pageAuthenticated = pageAuthenticated;
}
isFullyAuthenticated(): boolean {
return this.cookiesValid && this.pageAuthenticated;
}
getAuthenticationState(): AuthenticationState {
if (!this.cookiesValid) {
return AuthenticationState.UNKNOWN;
}
if (!this.pageAuthenticated) {
return AuthenticationState.EXPIRED;
}
return AuthenticationState.AUTHENTICATED;
}
requiresReauthentication(): boolean {
return !this.isFullyAuthenticated();
}
getCookieValidity(): boolean {
return this.cookiesValid;
}
getPageAuthenticationStatus(): boolean {
return this.pageAuthenticated;
}
}

View File

@@ -0,0 +1,42 @@
export type CheckoutConfirmationDecision = 'confirmed' | 'cancelled' | 'timeout';
const VALID_DECISIONS: CheckoutConfirmationDecision[] = [
'confirmed',
'cancelled',
'timeout',
];
export class CheckoutConfirmation {
private readonly _value: CheckoutConfirmationDecision;
private constructor(value: CheckoutConfirmationDecision) {
this._value = value;
}
static create(value: CheckoutConfirmationDecision): CheckoutConfirmation {
if (!VALID_DECISIONS.includes(value)) {
throw new Error('Invalid checkout confirmation decision');
}
return new CheckoutConfirmation(value);
}
get value(): CheckoutConfirmationDecision {
return this._value;
}
equals(other: CheckoutConfirmation): boolean {
return this._value === other._value;
}
isConfirmed(): boolean {
return this._value === 'confirmed';
}
isCancelled(): boolean {
return this._value === 'cancelled';
}
isTimeout(): boolean {
return this._value === 'timeout';
}
}

View File

@@ -0,0 +1,49 @@
export class CheckoutPrice {
private constructor(private readonly amountUsd: number) {
if (amountUsd < 0) {
throw new Error('Price cannot be negative');
}
if (amountUsd > 10000) {
throw new Error('Price exceeds maximum of $10,000');
}
}
static fromString(priceStr: string): CheckoutPrice {
const trimmed = priceStr.trim();
if (!trimmed.startsWith('$')) {
throw new Error('Invalid price format: missing dollar sign');
}
const dollarSignCount = (trimmed.match(/\$/g) || []).length;
if (dollarSignCount > 1) {
throw new Error('Invalid price format: multiple dollar signs');
}
const numericPart = trimmed.substring(1).replace(/,/g, '');
if (numericPart === '') {
throw new Error('Invalid price format: no numeric value');
}
const amount = parseFloat(numericPart);
if (isNaN(amount)) {
throw new Error('Invalid price format: not a valid number');
}
return new CheckoutPrice(amount);
}
toDisplayString(): string {
return `$${this.amountUsd.toFixed(2)}`;
}
getAmount(): number {
return this.amountUsd;
}
isZero(): boolean {
return this.amountUsd < 0.001;
}
}

View File

@@ -0,0 +1,51 @@
export enum CheckoutStateEnum {
READY = 'READY',
INSUFFICIENT_FUNDS = 'INSUFFICIENT_FUNDS',
UNKNOWN = 'UNKNOWN'
}
export class CheckoutState {
private constructor(private readonly state: CheckoutStateEnum) {}
static ready(): CheckoutState {
return new CheckoutState(CheckoutStateEnum.READY);
}
static insufficientFunds(): CheckoutState {
return new CheckoutState(CheckoutStateEnum.INSUFFICIENT_FUNDS);
}
static unknown(): CheckoutState {
return new CheckoutState(CheckoutStateEnum.UNKNOWN);
}
static fromButtonClasses(classes: string): CheckoutState {
const normalized = classes.toLowerCase().trim();
if (normalized.includes('btn-success')) {
return CheckoutState.ready();
}
if (normalized.includes('btn')) {
return CheckoutState.insufficientFunds();
}
return CheckoutState.unknown();
}
isReady(): boolean {
return this.state === CheckoutStateEnum.READY;
}
hasInsufficientFunds(): boolean {
return this.state === CheckoutStateEnum.INSUFFICIENT_FUNDS;
}
isUnknown(): boolean {
return this.state === CheckoutStateEnum.UNKNOWN;
}
getValue(): CheckoutStateEnum {
return this.state;
}
}

View File

@@ -0,0 +1,104 @@
interface Cookie {
name: string;
value: string;
domain: string;
path: string;
secure?: boolean;
httpOnly?: boolean;
sameSite?: 'Strict' | 'Lax' | 'None';
}
export class CookieConfiguration {
private readonly cookie: Cookie;
private readonly targetUrl: URL;
constructor(cookie: Cookie, targetUrl: string) {
this.cookie = cookie;
try {
this.targetUrl = new URL(targetUrl);
} catch (error) {
throw new Error(`Invalid target URL: ${targetUrl}`);
}
this.validate();
}
private validate(): void {
if (!this.isValidDomain()) {
throw new Error(
`Domain mismatch: Cookie domain "${this.cookie.domain}" is invalid for target "${this.targetUrl.hostname}"`
);
}
if (!this.isValidPath()) {
throw new Error(
`Path not valid: Cookie path "${this.cookie.path}" is invalid for target path "${this.targetUrl.pathname}"`
);
}
}
private isValidDomain(): boolean {
const targetHost = this.targetUrl.hostname;
const cookieDomain = this.cookie.domain;
// Empty domain is invalid
if (!cookieDomain) {
return false;
}
// Exact match
if (cookieDomain === targetHost) {
return true;
}
// Wildcard domain (e.g., ".iracing.com" matches "members-ng.iracing.com")
if (cookieDomain.startsWith('.')) {
const domainWithoutDot = cookieDomain.slice(1);
return targetHost === domainWithoutDot || targetHost.endsWith('.' + domainWithoutDot);
}
// Subdomain compatibility: Allow cookies from related subdomains if they share the same base domain
// Example: "members.iracing.com" → "members-ng.iracing.com" (both share "iracing.com")
if (this.isSameBaseDomain(cookieDomain, targetHost)) {
return true;
}
return false;
}
/**
* Check if two domains share the same base domain (last 2 parts)
* @example
* isSameBaseDomain('members.iracing.com', 'members-ng.iracing.com') // true
* isSameBaseDomain('example.com', 'iracing.com') // false
*/
private isSameBaseDomain(domain1: string, domain2: string): boolean {
const parts1 = domain1.split('.');
const parts2 = domain2.split('.');
// Need at least 2 parts (domain.tld) for valid comparison
if (parts1.length < 2 || parts2.length < 2) {
return false;
}
// Compare last 2 parts (e.g., "iracing.com")
const base1 = parts1.slice(-2).join('.');
const base2 = parts2.slice(-2).join('.');
return base1 === base2;
}
private isValidPath(): boolean {
// Empty path is invalid
if (!this.cookie.path) {
return false;
}
// Path must be prefix of target pathname
return this.targetUrl.pathname.startsWith(this.cookie.path);
}
getValidatedCookie(): Cookie {
return { ...this.cookie };
}
}

View File

@@ -0,0 +1,55 @@
export interface RaceCreationResultData {
sessionId: string;
price: string;
timestamp: Date;
}
export class RaceCreationResult {
private readonly _sessionId: string;
private readonly _price: string;
private readonly _timestamp: Date;
private constructor(data: RaceCreationResultData) {
this._sessionId = data.sessionId;
this._price = data.price;
this._timestamp = data.timestamp;
}
static create(data: RaceCreationResultData): RaceCreationResult {
if (!data.sessionId || data.sessionId.trim() === '') {
throw new Error('Session ID cannot be empty');
}
if (!data.price || data.price.trim() === '') {
throw new Error('Price cannot be empty');
}
return new RaceCreationResult(data);
}
get sessionId(): string {
return this._sessionId;
}
get price(): string {
return this._price;
}
get timestamp(): Date {
return this._timestamp;
}
equals(other: RaceCreationResult): boolean {
return (
this._sessionId === other._sessionId &&
this._price === other._price &&
this._timestamp.getTime() === other._timestamp.getTime()
);
}
toJSON(): { sessionId: string; price: string; timestamp: string } {
return {
sessionId: this._sessionId,
price: this._price,
timestamp: this._timestamp.toISOString(),
};
}
}

View File

@@ -0,0 +1,85 @@
/**
* SessionLifetime Value Object
*
* Represents the lifetime of an authentication session with expiry tracking.
* Handles validation of session expiry dates with a configurable buffer window.
*/
export class SessionLifetime {
private readonly expiry: Date | null;
private readonly bufferMinutes: number;
constructor(expiry: Date | null, bufferMinutes: number = 5) {
if (expiry !== null) {
if (isNaN(expiry.getTime())) {
throw new Error('Invalid expiry date provided');
}
// Allow dates within buffer window to support checking expiry of recently expired sessions
const bufferMs = bufferMinutes * 60 * 1000;
const expiryWithBuffer = expiry.getTime() + bufferMs;
if (expiryWithBuffer < Date.now()) {
throw new Error('Expiry date cannot be in the past');
}
}
this.expiry = expiry;
this.bufferMinutes = bufferMinutes;
}
/**
* Check if the session is expired.
* Considers the buffer time - sessions within the buffer window are treated as expired.
*
* @returns true if expired or expiring soon (within buffer), false otherwise
*/
isExpired(): boolean {
if (this.expiry === null) {
return false;
}
const bufferMs = this.bufferMinutes * 60 * 1000;
const expiryWithBuffer = this.expiry.getTime() - bufferMs;
return Date.now() >= expiryWithBuffer;
}
/**
* Check if the session is expiring soon (within buffer window).
*
* @returns true if expiring within buffer window, false otherwise
*/
isExpiringSoon(): boolean {
if (this.expiry === null) {
return false;
}
const bufferMs = this.bufferMinutes * 60 * 1000;
const now = Date.now();
const expiryTime = this.expiry.getTime();
const expiryWithBuffer = expiryTime - bufferMs;
return now >= expiryWithBuffer && now < expiryTime;
}
/**
* Get the expiry date.
*
* @returns The expiry date or null if no expiration
*/
getExpiry(): Date | null {
return this.expiry;
}
/**
* Get remaining time until expiry in milliseconds.
*
* @returns Milliseconds until expiry, or Infinity if no expiration
*/
getRemainingTime(): number {
if (this.expiry === null) {
return Infinity;
}
const remaining = this.expiry.getTime() - Date.now();
return Math.max(0, remaining);
}
}

View File

@@ -4,7 +4,9 @@ export type SessionStateValue =
| 'PAUSED'
| 'COMPLETED'
| 'FAILED'
| 'STOPPED_AT_STEP_18';
| 'STOPPED_AT_STEP_18'
| 'AWAITING_CHECKOUT_CONFIRMATION'
| 'CANCELLED';
const VALID_STATES: SessionStateValue[] = [
'PENDING',
@@ -13,15 +15,19 @@ const VALID_STATES: SessionStateValue[] = [
'COMPLETED',
'FAILED',
'STOPPED_AT_STEP_18',
'AWAITING_CHECKOUT_CONFIRMATION',
'CANCELLED',
];
const VALID_TRANSITIONS: Record<SessionStateValue, SessionStateValue[]> = {
PENDING: ['IN_PROGRESS', 'FAILED'],
IN_PROGRESS: ['PAUSED', 'COMPLETED', 'FAILED', 'STOPPED_AT_STEP_18'],
IN_PROGRESS: ['PAUSED', 'COMPLETED', 'FAILED', 'STOPPED_AT_STEP_18', 'AWAITING_CHECKOUT_CONFIRMATION'],
PAUSED: ['IN_PROGRESS', 'FAILED'],
COMPLETED: [],
FAILED: [],
STOPPED_AT_STEP_18: [],
AWAITING_CHECKOUT_CONFIRMATION: ['COMPLETED', 'CANCELLED', 'FAILED'],
CANCELLED: [],
};
export class SessionState {
@@ -66,6 +72,14 @@ export class SessionState {
return this._value === 'STOPPED_AT_STEP_18';
}
isAwaitingCheckoutConfirmation(): boolean {
return this._value === 'AWAITING_CHECKOUT_CONFIRMATION';
}
isCancelled(): boolean {
return this._value === 'CANCELLED';
}
canTransitionTo(targetState: SessionState): boolean {
const allowedTransitions = VALID_TRANSITIONS[this._value];
return allowedTransitions.includes(targetState._value);
@@ -75,7 +89,8 @@ export class SessionState {
return (
this._value === 'COMPLETED' ||
this._value === 'FAILED' ||
this._value === 'STOPPED_AT_STEP_18'
this._value === 'STOPPED_AT_STEP_18' ||
this._value === 'CANCELLED'
);
}
}