Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<img src="https://img.shields.io/badge/NestJS-framework-E0234E?logo=nestjs&logoColor=white" alt="NestJS" />
<img src="https://img.shields.io/badge/PostgreSQL-database-4169E1?logo=postgresql&logoColor=white" alt="PostgreSQL" />
<img src="https://img.shields.io/badge/License-Apache%202.0-blue" alt="Apache 2.0 License" />
<img src="https://img.shields.io/badge/Tests-1598%20passing-brightgreen" alt="1598 Tests Passing" /> <img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
<img src="https://img.shields.io/badge/Tests-1602%20passing-brightgreen" alt="1602 Tests Passing" /> <img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
</p>

<p align="center">
Expand Down Expand Up @@ -510,7 +510,7 @@ Operator notes for activating existing adapters, metasearch landings on the dire
| OTA Channels | Booking.com + Expedia (EQC) + SiteMinder + DerbySoft | Direct + aggregated OTA connectivity (ARI + content) |
| XML Processing | fast-xml-parser | Booking.com OTA XML protocol |
| Package Manager | pnpm workspaces | Monorepo management |
| Testing | Vitest (1598 tests across 221 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |
| Testing | Vitest (1602 tests across 223 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |
| Containers | Docker + docker-compose | Local dev and production deployment |
| CI/CD | GitHub Actions | Automated testing, builds, and releases |

Expand Down Expand Up @@ -642,7 +642,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment.
### Run tests

```bash
# All tests (1598 tests across 221 test files)
# All tests (1602 tests across 223 test files)

# API tests only
pnpm --filter @telivityhaip/api test
Expand Down Expand Up @@ -1190,7 +1190,7 @@ HAIP is built in public and contributions are welcome.
pnpm install # Install dependencies
pnpm build # Build all workspace packages
pnpm dev # Start API in dev mode (hot reload)
pnpm test # Run all tests (1598 tests, 221 files)
pnpm test # Run all tests (1602 tests, 223 files)
pnpm lint # ESLint
```

Expand Down
13 changes: 13 additions & 0 deletions apps/api/src/common/crypto/confirmation-number.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { describe, expect, it, vi } from 'vitest';
import { generateConfirmationNumber } from './confirmation-number';

describe('generateConfirmationNumber', () => {
it('uses cryptographically random 128-bit entropy via an injectable seam', () => {
const entropy = vi.fn((bytes: number) => Buffer.alloc(bytes, 0xa5));

const confirmation = generateConfirmationNumber(entropy);

expect(entropy).toHaveBeenCalledWith(16);
expect(confirmation).toMatch(/^HAIP-[0-9A-HJKMNP-TV-Z]{32}$/);
});
});
29 changes: 29 additions & 0 deletions apps/api/src/common/crypto/confirmation-number.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { randomBytes } from 'node:crypto';

const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';

export type ConfirmationEntropy = (bytes: number) => Uint8Array;

/** A cryptographically random guest-facing bearer credential (128 bits of entropy). */
export function generateConfirmationNumber(
entropy: ConfirmationEntropy = randomBytes,
): string {
const bytes = entropy(16);
if (bytes.length !== 16) {
throw new Error('Confirmation entropy source must return 16 bytes');
}
let token = '';
for (const byte of bytes) {
token += CROCKFORD[byte & 0x1f];
token += CROCKFORD[(byte >> 5) & 0x1f];
}
return `HAIP-${token}`;
}

/** Crockford base32 token without the HAIP- prefix (for channel/connect prefixes). */
export function generateConfirmationToken(
entropy: ConfirmationEntropy = randomBytes,
): string {
const confirmation = generateConfirmationNumber(entropy);
return confirmation.startsWith('HAIP-') ? confirmation.slice(5) : confirmation;
}
16 changes: 16 additions & 0 deletions apps/api/src/common/date/property-business-date.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { calendarDateInTimeZone } from './property-business-date';

describe('calendarDateInTimeZone', () => {
it('returns the property calendar date at a UTC midnight boundary', () => {
// 2024-06-02 00:30 UTC is still 2024-06-01 evening in US Eastern.
const instant = new Date('2024-06-02T00:30:00.000Z');
expect(calendarDateInTimeZone(instant, 'America/New_York')).toBe('2024-06-01');
expect(calendarDateInTimeZone(instant, 'UTC')).toBe('2024-06-02');
});

it('falls back to UTC when the timezone is invalid', () => {
const instant = new Date('2024-06-02T12:00:00.000Z');
expect(calendarDateInTimeZone(instant, 'Not/A_Timezone')).toBe('2024-06-02');
});
});
18 changes: 18 additions & 0 deletions apps/api/src/common/date/property-business-date.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** Resolve a YYYY-MM-DD calendar date in an IANA timezone without server-UTC leakage. */
export function calendarDateInTimeZone(now: Date, timeZone: string): string {
try {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(now);
const value = Object.fromEntries(parts.map((part) => [part.type, part.value]));
if (value['year'] && value['month'] && value['day']) {
return `${value['year']}-${value['month']}-${value['day']}`;
}
} catch {
// Invalid legacy timezone values fall back to UTC, matching other PMS dates.
}
return now.toISOString().slice(0, 10);
}
10 changes: 10 additions & 0 deletions apps/api/src/common/validation/is-money-string.validator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ class Signed {
amount!: string;
}

class Bounded {
@IsMoneyString({ maximum: '100.00' })
amount!: string;
}

async function fails(obj: any): Promise<boolean> {
const errors = await validate(obj);
return errors.length > 0;
Expand Down Expand Up @@ -42,4 +47,9 @@ describe('IsMoneyString', () => {
expect(await fails(Object.assign(new Signed(), { amount: '50.00' }))).toBe(false);
expect(await fails(Object.assign(new Signed(), { amount: 'nope' }))).toBe(true);
});

it('maximum: accepts the inclusive boundary and rejects values above it', async () => {
expect(await fails(Object.assign(new Bounded(), { amount: '100.00' }))).toBe(false);
expect(await fails(Object.assign(new Bounded(), { amount: '100.01' }))).toBe(true);
});
});
7 changes: 6 additions & 1 deletion apps/api/src/common/validation/is-money-string.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface MoneyStringOptions {
allowZero?: boolean;
/** Allow negative amounts (default false). Use for credit/adjustment fields. */
allowNegative?: boolean;
/** Inclusive upper bound, expressed as a decimal string. */
maximum?: string;
}

@ValidatorConstraint({ name: 'isMoneyString', async: false })
Expand All @@ -27,6 +29,7 @@ class MoneyStringConstraint implements ValidatorConstraintInterface {
const opts: MoneyStringOptions = args?.constraints?.[0] ?? {};
if (!opts.allowNegative && d.isNegative()) return false;
if (!opts.allowZero && !opts.allowNegative && d.isZero()) return false;
if (opts.maximum != null && d.gt(new Decimal(opts.maximum))) return false;
return true;
}

Expand All @@ -37,7 +40,9 @@ class MoneyStringConstraint implements ValidatorConstraintInterface {
: opts.allowZero
? 'a non-negative numeric decimal string'
: 'a positive numeric decimal string';
return `${args?.property} must be ${bound}`;
return opts.maximum == null
? `${args?.property} must be ${bound}`
: `${args?.property} must be ${bound} no greater than ${opts.maximum}`;
}
}

Expand Down
21 changes: 2 additions & 19 deletions apps/api/src/modules/connect/connect-booking.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { RatePlanService } from '../rate-plan/rate-plan.service';
import { PolicyService } from '../policy/policy.service';
import type { AgentBookDto } from './dto/agent-book.dto';
import type { AgentModifyDto } from './dto/agent-modify.dto';
import { randomBytes } from 'crypto';
import { generateConfirmationToken } from '../../common/crypto/confirmation-number';

@Injectable()
export class ConnectBookingService {
Expand Down Expand Up @@ -553,21 +553,4 @@ export class ConnectBookingService {
}
}

// Crockford base32 alphabet (no I/L/O/U — unambiguous when read/typed).
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';

/**
* 128 bits of cryptographic randomness (16 random bytes) rendered in Crockford
* base32. Unguessable — the confirmation number is a bearer credential for the
* booking, so it must not be enumerable (the old `timestamp-4hex` form had only
* ~16 bits of randomness).
*/
export function generateConfirmationToken(): string {
const bytes = randomBytes(16);
let out = '';
for (let i = 0; i < bytes.length; i++) {
out += CROCKFORD[bytes[i]! & 0x1f];
out += CROCKFORD[(bytes[i]! >> 5) & 0x1f];
}
return out;
}
export { generateConfirmationToken } from '../../common/crypto/confirmation-number';
6 changes: 3 additions & 3 deletions docs/test-stats.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"tests": 1598,
"files": 221,
"updatedAt": "2026-08-27T09:12:56.919Z"
"tests": 1602,
"files": 223,
"updatedAt": "2026-08-27T10:58:08.378Z"
}
Loading