wip
This commit is contained in:
39
packages/domain/value-objects/BrowserAuthenticationState.ts
Normal file
39
packages/domain/value-objects/BrowserAuthenticationState.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
42
packages/domain/value-objects/CheckoutConfirmation.ts
Normal file
42
packages/domain/value-objects/CheckoutConfirmation.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
49
packages/domain/value-objects/CheckoutPrice.ts
Normal file
49
packages/domain/value-objects/CheckoutPrice.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
51
packages/domain/value-objects/CheckoutState.ts
Normal file
51
packages/domain/value-objects/CheckoutState.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
104
packages/domain/value-objects/CookieConfiguration.ts
Normal file
104
packages/domain/value-objects/CookieConfiguration.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
55
packages/domain/value-objects/RaceCreationResult.ts
Normal file
55
packages/domain/value-objects/RaceCreationResult.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
85
packages/domain/value-objects/SessionLifetime.ts
Normal file
85
packages/domain/value-objects/SessionLifetime.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user