From f99ccb70ac58af99029bcc8958eeec5e69c6003c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 18:33:19 +0000 Subject: [PATCH 1/3] feat(api): add shared confirmation number and property date helpers Add confirmation-number crypto helper, property-business-date utility, and tighten is-money-string validation for decimal amounts. Co-authored-by: Agus --- .../common/crypto/confirmation-number.spec.ts | 13 ++++++++++++ .../src/common/crypto/confirmation-number.ts | 21 +++++++++++++++++++ .../src/common/date/property-business-date.ts | 18 ++++++++++++++++ .../validation/is-money-string.validator.ts | 7 ++++++- 4 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/common/crypto/confirmation-number.spec.ts create mode 100644 apps/api/src/common/crypto/confirmation-number.ts create mode 100644 apps/api/src/common/date/property-business-date.ts diff --git a/apps/api/src/common/crypto/confirmation-number.spec.ts b/apps/api/src/common/crypto/confirmation-number.spec.ts new file mode 100644 index 00000000..038113f2 --- /dev/null +++ b/apps/api/src/common/crypto/confirmation-number.spec.ts @@ -0,0 +1,13 @@ +import { describe, expect, it, vi } from 'vitest'; +import { generateConfirmationNumber } from './confirmation-number'; + +describe('generateConfirmationNumber', () => { + it('uses a 128-bit cryptographic entropy seam and a non-enumerable shape', () => { + 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}$/); + }); +}); diff --git a/apps/api/src/common/crypto/confirmation-number.ts b/apps/api/src/common/crypto/confirmation-number.ts new file mode 100644 index 00000000..171e3c9b --- /dev/null +++ b/apps/api/src/common/crypto/confirmation-number.ts @@ -0,0 +1,21 @@ +import { randomBytes } from 'node:crypto'; + +const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + +export type ConfirmationEntropy = (bytes: number) => Uint8Array; + +/** A guest-facing bearer credential backed by exactly 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}`; +} diff --git a/apps/api/src/common/date/property-business-date.ts b/apps/api/src/common/date/property-business-date.ts new file mode 100644 index 00000000..20f05b8f --- /dev/null +++ b/apps/api/src/common/date/property-business-date.ts @@ -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); +} diff --git a/apps/api/src/common/validation/is-money-string.validator.ts b/apps/api/src/common/validation/is-money-string.validator.ts index 71f6a9ea..1bfa5f3d 100644 --- a/apps/api/src/common/validation/is-money-string.validator.ts +++ b/apps/api/src/common/validation/is-money-string.validator.ts @@ -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 }) @@ -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; } @@ -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}`; } } From e7a6019cecb6fc2d3e2ab8a51557053a9bd72607 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 19:57:05 +0000 Subject: [PATCH 2/3] chore: sync README test counts for CI Co-authored-by: telivity-otaip --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0459cd55..567dfb39 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ NestJS PostgreSQL Apache 2.0 License -1568 Tests Passing 12 AI Agents +1569 Tests Passing 12 AI Agents

@@ -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 (1568 tests across 218 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds | +| Testing | Vitest (1569 tests across 219 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 | @@ -642,7 +642,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment. ### Run tests ```bash -# All tests (1568 tests across 218 test files) +# All tests (1569 tests across 219 test files) # API tests only pnpm --filter @telivityhaip/api test @@ -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 (1568 tests, 218 files) +pnpm test # Run all tests (1569 tests, 219 files) pnpm lint # ESLint ``` From 0bb6a4bf26971952926a9233b18f93cc3b115ae8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 27 Aug 2026 00:50:14 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(utils):=20address=20#351=20review=20?= =?UTF-8?q?=E2=80=94=20tests,=20wording,=20unify=20confirmation=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add property-business-date and money maximum tests. Clarify that confirmation codes are cryptographically random. Route connect generateConfirmationToken through the shared helper. Co-authored-by: telivity-otaip --- .../common/crypto/confirmation-number.spec.ts | 2 +- .../src/common/crypto/confirmation-number.ts | 10 ++++++++- .../date/property-business-date.spec.ts | 16 ++++++++++++++ .../is-money-string.validator.spec.ts | 10 +++++++++ .../connect/connect-booking.service.ts | 21 ++----------------- 5 files changed, 38 insertions(+), 21 deletions(-) create mode 100644 apps/api/src/common/date/property-business-date.spec.ts diff --git a/apps/api/src/common/crypto/confirmation-number.spec.ts b/apps/api/src/common/crypto/confirmation-number.spec.ts index 038113f2..e2e1abef 100644 --- a/apps/api/src/common/crypto/confirmation-number.spec.ts +++ b/apps/api/src/common/crypto/confirmation-number.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { generateConfirmationNumber } from './confirmation-number'; describe('generateConfirmationNumber', () => { - it('uses a 128-bit cryptographic entropy seam and a non-enumerable shape', () => { + 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); diff --git a/apps/api/src/common/crypto/confirmation-number.ts b/apps/api/src/common/crypto/confirmation-number.ts index 171e3c9b..67d56458 100644 --- a/apps/api/src/common/crypto/confirmation-number.ts +++ b/apps/api/src/common/crypto/confirmation-number.ts @@ -4,7 +4,7 @@ const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; export type ConfirmationEntropy = (bytes: number) => Uint8Array; -/** A guest-facing bearer credential backed by exactly 128 bits of entropy. */ +/** A cryptographically random guest-facing bearer credential (128 bits of entropy). */ export function generateConfirmationNumber( entropy: ConfirmationEntropy = randomBytes, ): string { @@ -19,3 +19,11 @@ export function generateConfirmationNumber( } 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; +} diff --git a/apps/api/src/common/date/property-business-date.spec.ts b/apps/api/src/common/date/property-business-date.spec.ts new file mode 100644 index 00000000..177f4daf --- /dev/null +++ b/apps/api/src/common/date/property-business-date.spec.ts @@ -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'); + }); +}); diff --git a/apps/api/src/common/validation/is-money-string.validator.spec.ts b/apps/api/src/common/validation/is-money-string.validator.spec.ts index 90e98b5c..28028693 100644 --- a/apps/api/src/common/validation/is-money-string.validator.spec.ts +++ b/apps/api/src/common/validation/is-money-string.validator.spec.ts @@ -15,6 +15,11 @@ class Signed { amount!: string; } +class Bounded { + @IsMoneyString({ maximum: '100.00' }) + amount!: string; +} + async function fails(obj: any): Promise { const errors = await validate(obj); return errors.length > 0; @@ -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); + }); }); diff --git a/apps/api/src/modules/connect/connect-booking.service.ts b/apps/api/src/modules/connect/connect-booking.service.ts index a613da4a..5e67470a 100644 --- a/apps/api/src/modules/connect/connect-booking.service.ts +++ b/apps/api/src/modules/connect/connect-booking.service.ts @@ -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 { @@ -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';