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 @@
-
+
@@ -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';