From 737ae6bcc8c0133989c048ad16ee1c36675e2eaf Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Tue, 11 Aug 2026 00:54:43 +0000
Subject: [PATCH 1/2] feat(migration): encrypted credential vault for
source-PMS tokens (TEL-70)
Add AES-256-GCM app-level encryption for per-property source-PMS migration
credentials with key-rotation support via encryption_key_id.
- migration_source_credentials table (property-scoped, unique per source PMS)
- MigrationSourceCredentialsService with encrypt/decrypt/upsert/delete APIs
- HTTP endpoints return metadata only; decryptForRunner is server-side only
- Audit logs and API responses never include plaintext secrets or ciphertext
- Tests cover round-trip, wrong-key failure, and GCM tamper detection
Co-authored-by: telivity-otaip
---
.env.example | 7 +
apps/api/src/app.module.ts | 2 +
.../crypto/credential-encryption.spec.ts | 83 +++++
.../common/crypto/credential-encryption.ts | 175 ++++++++++
.../dto/upsert-migration-credential.dto.ts | 17 +
...gration-source-credentials.service.spec.ts | 278 ++++++++++++++++
.../migration-source-credentials.service.ts | 301 ++++++++++++++++++
.../migration/migration.controller.spec.ts | 29 ++
.../modules/migration/migration.controller.ts | 86 +++++
.../src/modules/migration/migration.module.ts | 12 +
.../0019_migration_source_credentials.sql | 17 +
packages/database/src/push-schema.ts | 20 ++
packages/database/src/schema/index.ts | 3 +
packages/database/src/schema/migration.ts | 32 ++
packages/shared/src/index.ts | 4 +
15 files changed, 1066 insertions(+)
create mode 100644 apps/api/src/common/crypto/credential-encryption.spec.ts
create mode 100644 apps/api/src/common/crypto/credential-encryption.ts
create mode 100644 apps/api/src/modules/migration/dto/upsert-migration-credential.dto.ts
create mode 100644 apps/api/src/modules/migration/migration-source-credentials.service.spec.ts
create mode 100644 apps/api/src/modules/migration/migration-source-credentials.service.ts
create mode 100644 apps/api/src/modules/migration/migration.controller.spec.ts
create mode 100644 apps/api/src/modules/migration/migration.controller.ts
create mode 100644 apps/api/src/modules/migration/migration.module.ts
create mode 100644 packages/database/src/migrations/0019_migration_source_credentials.sql
create mode 100644 packages/database/src/schema/migration.ts
diff --git a/.env.example b/.env.example
index 7385cf41..99fae497 100644
--- a/.env.example
+++ b/.env.example
@@ -103,6 +103,13 @@ STORAGE_DRIVER=local
# S3_FORCE_PATH_STYLE=true # true for MinIO; false for AWS S3
# S3_PUBLIC_BASE_URL= # optional CDN/public base for object URLs
+# Migration source-PMS credential vault (AES-256-GCM at rest)
+# Generate: openssl rand -hex 32
+# MIGRATION_CREDENTIAL_ENCRYPTION_KEY=
+# MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID=default
+# Optional rotation map — JSON object of keyId → 64-char hex key
+# MIGRATION_CREDENTIAL_ENCRYPTION_KEYS={"legacy":"<64 hex chars>"}
+
# Channex channel manager (optional env fallbacks; prefer connection.config)
# CHANNEX_API_KEY=
# CHANNEX_PROPERTY_ID=
diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts
index eeea7b9b..70fd0102 100644
--- a/apps/api/src/app.module.ts
+++ b/apps/api/src/app.module.ts
@@ -52,6 +52,7 @@ import { LoyaltyModule } from './modules/loyalty/loyalty.module';
import { IntegrationsModule } from './modules/integrations/integrations.module';
import { IcalModule } from './modules/ical/ical.module';
import { FiscalModule } from './modules/fiscal/fiscal.module';
+import { MigrationModule } from './modules/migration/migration.module';
const imports: any[] = [
ConfigModule.forRoot({
@@ -106,6 +107,7 @@ const imports: any[] = [
IntegrationsModule,
IcalModule,
FiscalModule,
+ MigrationModule,
];
// Serve the bundled dashboard as static files. Enabled in production, or
diff --git a/apps/api/src/common/crypto/credential-encryption.spec.ts b/apps/api/src/common/crypto/credential-encryption.spec.ts
new file mode 100644
index 00000000..c6473308
--- /dev/null
+++ b/apps/api/src/common/crypto/credential-encryption.spec.ts
@@ -0,0 +1,83 @@
+import { randomBytes } from 'node:crypto';
+import { describe, expect, it } from 'vitest';
+import {
+ CredentialEncryptionError,
+ decryptCredentialPlaintext,
+ encryptCredentialPlaintext,
+ loadMigrationCredentialKeyRingFromEnv,
+ parseAes256KeyHex,
+ serializeEncryptedBlob,
+ deserializeEncryptedBlob,
+} from './credential-encryption';
+
+const KEY_A = randomBytes(32).toString('hex');
+const KEY_B = randomBytes(32).toString('hex');
+
+function env(overrides: Record = {}): NodeJS.ProcessEnv {
+ return {
+ MIGRATION_CREDENTIAL_ENCRYPTION_KEY: KEY_A,
+ MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID: 'default',
+ ...overrides,
+ };
+}
+
+describe('credential-encryption', () => {
+ it('round-trips plaintext', () => {
+ const keyRing = loadMigrationCredentialKeyRingFromEnv(env());
+ const blob = encryptCredentialPlaintext('{"apiKey":"secret"}', keyRing, env());
+ const out = decryptCredentialPlaintext(blob, keyRing);
+ expect(out).toBe('{"apiKey":"secret"}');
+ });
+
+ it('fails closed when decrypting with a wrong key ring', () => {
+ const keyRing = loadMigrationCredentialKeyRingFromEnv(env());
+ const blob = encryptCredentialPlaintext('sensitive', keyRing, env());
+ const wrongRing = loadMigrationCredentialKeyRingFromEnv(
+ env({ MIGRATION_CREDENTIAL_ENCRYPTION_KEY: KEY_B }),
+ );
+ expect(() => decryptCredentialPlaintext(blob, wrongRing)).toThrow(CredentialEncryptionError);
+ });
+
+ it('fails closed when the GCM auth tag is tampered', () => {
+ const keyRing = loadMigrationCredentialKeyRingFromEnv(env());
+ const blob = encryptCredentialPlaintext('sensitive', keyRing, env());
+ const tampered = { ...blob, authTag: '0'.repeat(blob.authTag.length) };
+ expect(() => decryptCredentialPlaintext(tampered, keyRing)).toThrow(CredentialEncryptionError);
+ });
+
+ it('fails closed when ciphertext is tampered', () => {
+ const keyRing = loadMigrationCredentialKeyRingFromEnv(env());
+ const blob = encryptCredentialPlaintext('sensitive', keyRing, env());
+ const tampered = { ...blob, ciphertext: blob.ciphertext.replace(/a/g, 'b') };
+ expect(() => decryptCredentialPlaintext(tampered, keyRing)).toThrow(CredentialEncryptionError);
+ });
+
+ it('decrypts with a rotated legacy key id from the key ring', () => {
+ const legacyEnv = env({
+ MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID: 'legacy',
+ MIGRATION_CREDENTIAL_ENCRYPTION_KEY: KEY_B,
+ MIGRATION_CREDENTIAL_ENCRYPTION_KEYS: JSON.stringify({ legacy: KEY_B }),
+ });
+ const legacyRing = loadMigrationCredentialKeyRingFromEnv(legacyEnv);
+ const blob = encryptCredentialPlaintext('rotated-secret', legacyRing, legacyEnv);
+ expect(blob.keyId).toBe('legacy');
+
+ const currentRing = loadMigrationCredentialKeyRingFromEnv(
+ env({
+ MIGRATION_CREDENTIAL_ENCRYPTION_KEYS: JSON.stringify({ legacy: KEY_B }),
+ }),
+ );
+ expect(decryptCredentialPlaintext(blob, currentRing)).toBe('rotated-secret');
+ });
+
+ it('serializes and deserializes blobs for DB storage', () => {
+ const keyRing = loadMigrationCredentialKeyRingFromEnv(env());
+ const blob = encryptCredentialPlaintext('x', keyRing, env());
+ const roundTrip = deserializeEncryptedBlob(serializeEncryptedBlob(blob));
+ expect(roundTrip).toEqual(blob);
+ });
+
+ it('rejects invalid key hex length', () => {
+ expect(() => parseAes256KeyHex('abcd', 'test')).toThrow(CredentialEncryptionError);
+ });
+});
diff --git a/apps/api/src/common/crypto/credential-encryption.ts b/apps/api/src/common/crypto/credential-encryption.ts
new file mode 100644
index 00000000..49ee95ba
--- /dev/null
+++ b/apps/api/src/common/crypto/credential-encryption.ts
@@ -0,0 +1,175 @@
+import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
+
+/** AES-256-GCM encrypted blob with key-rotation metadata. */
+export interface EncryptedCredentialBlob {
+ keyId: string;
+ iv: string;
+ ciphertext: string;
+ authTag: string;
+}
+
+export type CredentialKeyRing = ReadonlyMap;
+
+const AES_ALGO = 'aes-256-gcm';
+const IV_BYTES = 16;
+const KEY_BYTES = 32;
+
+export class CredentialEncryptionError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'CredentialEncryptionError';
+ }
+}
+
+/** Parse a 64-char hex string into a 32-byte AES-256 key. */
+export function parseAes256KeyHex(hex: string, label: string): Buffer {
+ const key = Buffer.from(hex, 'hex');
+ if (key.length !== KEY_BYTES) {
+ throw new CredentialEncryptionError(
+ `${label} must be ${KEY_BYTES * 2} hex characters (${KEY_BYTES} bytes)`,
+ );
+ }
+ return key;
+}
+
+/**
+ * Load encryption keys from env for migration source-PMS credentials.
+ *
+ * - `MIGRATION_CREDENTIAL_ENCRYPTION_KEY` — primary key (hex)
+ * - `MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID` — id for the primary key (default: "default")
+ * - `MIGRATION_CREDENTIAL_ENCRYPTION_KEYS` — optional JSON map of keyId → hex for rotation
+ */
+export function loadMigrationCredentialKeyRingFromEnv(
+ env: NodeJS.ProcessEnv = process.env,
+): CredentialKeyRing {
+ const keys = new Map();
+ const primaryHex = env['MIGRATION_CREDENTIAL_ENCRYPTION_KEY'];
+ const primaryId = env['MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID'] ?? 'default';
+ if (primaryHex) {
+ keys.set(primaryId, parseAes256KeyHex(primaryHex, 'MIGRATION_CREDENTIAL_ENCRYPTION_KEY'));
+ }
+
+ const extraJson = env['MIGRATION_CREDENTIAL_ENCRYPTION_KEYS'];
+ if (extraJson) {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(extraJson);
+ } catch {
+ throw new CredentialEncryptionError(
+ 'MIGRATION_CREDENTIAL_ENCRYPTION_KEYS must be valid JSON',
+ );
+ }
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw new CredentialEncryptionError(
+ 'MIGRATION_CREDENTIAL_ENCRYPTION_KEYS must be a JSON object',
+ );
+ }
+ for (const [id, hex] of Object.entries(parsed as Record)) {
+ if (typeof hex !== 'string') {
+ throw new CredentialEncryptionError(
+ `MIGRATION_CREDENTIAL_ENCRYPTION_KEYS["${id}"] must be a hex string`,
+ );
+ }
+ keys.set(id, parseAes256KeyHex(hex, `MIGRATION_CREDENTIAL_ENCRYPTION_KEYS["${id}"]`));
+ }
+ }
+
+ return keys;
+}
+
+/** Resolve the active key id + material for new encryptions. */
+export function resolveActiveMigrationCredentialKey(
+ keyRing: CredentialKeyRing,
+ env: NodeJS.ProcessEnv = process.env,
+): { keyId: string; key: Buffer } {
+ if (keyRing.size === 0) {
+ throw new CredentialEncryptionError(
+ 'MIGRATION_CREDENTIAL_ENCRYPTION_KEY is not configured',
+ );
+ }
+ const activeId = env['MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID'] ?? 'default';
+ const key = keyRing.get(activeId);
+ if (!key) {
+ throw new CredentialEncryptionError(
+ `Active encryption key id "${activeId}" is not in the key ring`,
+ );
+ }
+ return { keyId: activeId, key };
+}
+
+/** Encrypt a UTF-8 plaintext string with AES-256-GCM. */
+export function encryptCredentialPlaintext(
+ plaintext: string,
+ keyRing: CredentialKeyRing,
+ env: NodeJS.ProcessEnv = process.env,
+): EncryptedCredentialBlob {
+ const { keyId, key } = resolveActiveMigrationCredentialKey(keyRing, env);
+ const iv = randomBytes(IV_BYTES);
+ const cipher = createCipheriv(AES_ALGO, key, iv);
+ let ciphertext = cipher.update(plaintext, 'utf8', 'hex');
+ ciphertext += cipher.final('hex');
+ const authTag = cipher.getAuthTag().toString('hex');
+ return {
+ keyId,
+ iv: iv.toString('hex'),
+ ciphertext,
+ authTag,
+ };
+}
+
+/** Decrypt an AES-256-GCM blob. Fails closed on wrong/missing key or tampered ciphertext. */
+export function decryptCredentialPlaintext(
+ blob: EncryptedCredentialBlob,
+ keyRing: CredentialKeyRing,
+): string {
+ const key = keyRing.get(blob.keyId);
+ if (!key) {
+ throw new CredentialEncryptionError(
+ `Encryption key id "${blob.keyId}" is not available`,
+ );
+ }
+ const decipher = createDecipheriv(AES_ALGO, key, Buffer.from(blob.iv, 'hex'));
+ decipher.setAuthTag(Buffer.from(blob.authTag, 'hex'));
+ try {
+ let plaintext = decipher.update(blob.ciphertext, 'hex', 'utf8');
+ plaintext += decipher.final('utf8');
+ return plaintext;
+ } catch {
+ throw new CredentialEncryptionError('Credential decryption failed (wrong key or tampered data)');
+ }
+}
+
+/** Serialize blob columns for DB storage (single text column). */
+export function serializeEncryptedBlob(blob: EncryptedCredentialBlob): string {
+ return JSON.stringify(blob);
+}
+
+/** Deserialize blob from DB storage. */
+export function deserializeEncryptedBlob(serialized: string): EncryptedCredentialBlob {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(serialized);
+ } catch {
+ throw new CredentialEncryptionError('Stored credential ciphertext is not valid JSON');
+ }
+ if (
+ parsed === null ||
+ typeof parsed !== 'object' ||
+ !('keyId' in parsed) ||
+ !('iv' in parsed) ||
+ !('ciphertext' in parsed) ||
+ !('authTag' in parsed)
+ ) {
+ throw new CredentialEncryptionError('Stored credential ciphertext is missing required fields');
+ }
+ const blob = parsed as EncryptedCredentialBlob;
+ if (
+ typeof blob.keyId !== 'string' ||
+ typeof blob.iv !== 'string' ||
+ typeof blob.ciphertext !== 'string' ||
+ typeof blob.authTag !== 'string'
+ ) {
+ throw new CredentialEncryptionError('Stored credential ciphertext has invalid field types');
+ }
+ return blob;
+}
diff --git a/apps/api/src/modules/migration/dto/upsert-migration-credential.dto.ts b/apps/api/src/modules/migration/dto/upsert-migration-credential.dto.ts
new file mode 100644
index 00000000..fe790eba
--- /dev/null
+++ b/apps/api/src/modules/migration/dto/upsert-migration-credential.dto.ts
@@ -0,0 +1,17 @@
+import { IsIn, IsObject } from 'class-validator';
+import { ApiProperty } from '@nestjs/swagger';
+import { MIGRATION_SOURCE_PMS } from '@telivityhaip/shared';
+
+export class UpsertMigrationCredentialDto {
+ @ApiProperty({ enum: MIGRATION_SOURCE_PMS, example: 'mews' })
+ @IsIn([...MIGRATION_SOURCE_PMS])
+ sourcePms!: (typeof MIGRATION_SOURCE_PMS)[number];
+
+ @ApiProperty({
+ description:
+ 'Source-PMS credential payload (API keys, tokens). Encrypted at rest; never returned by the API.',
+ example: { clientToken: '***', accessToken: '***' },
+ })
+ @IsObject()
+ credentials!: Record;
+}
diff --git a/apps/api/src/modules/migration/migration-source-credentials.service.spec.ts b/apps/api/src/modules/migration/migration-source-credentials.service.spec.ts
new file mode 100644
index 00000000..904ce6f9
--- /dev/null
+++ b/apps/api/src/modules/migration/migration-source-credentials.service.spec.ts
@@ -0,0 +1,278 @@
+import { randomBytes } from 'node:crypto';
+import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { MigrationSourceCredentialsService } from './migration-source-credentials.service';
+
+const KEY_HEX = randomBytes(32).toString('hex');
+
+vi.mock('drizzle-orm', () => ({
+ and: vi.fn((...conditions: any[]) => ({ op: 'and', conditions })),
+ desc: vi.fn((column: unknown) => ({ op: 'desc', column })),
+ eq: vi.fn((column: unknown, value: unknown) => ({ op: 'eq', column, value })),
+}));
+
+vi.mock('@telivityhaip/database', () => ({
+ auditLogs: {
+ __table: 'auditLogs',
+ propertyId: 'audit.propertyId',
+ action: 'audit.action',
+ entityType: 'audit.entityType',
+ entityId: 'audit.entityId',
+ description: 'audit.description',
+ newValue: 'audit.newValue',
+ },
+ migrationSourceCredentials: {
+ __table: 'migrationSourceCredentials',
+ id: 'cred.id',
+ propertyId: 'cred.propertyId',
+ sourcePms: 'cred.sourcePms',
+ ciphertext: 'cred.ciphertext',
+ encryptionKeyId: 'cred.encryptionKeyId',
+ createdAt: 'cred.createdAt',
+ rotatedAt: 'cred.rotatedAt',
+ updatedAt: 'cred.updatedAt',
+ },
+}));
+
+const PROP = '11111111-1111-4111-8111-111111111111';
+const OTHER_PROP = '22222222-2222-4222-8222-222222222222';
+const CRED_ID = '33333333-3333-4333-8333-333333333333';
+const CREATED_AT = new Date('2026-01-01T00:00:00Z');
+
+function metadataRow(overrides: Record = {}) {
+ return {
+ id: CRED_ID,
+ propertyId: PROP,
+ sourcePms: 'mews',
+ encryptionKeyId: 'default',
+ createdAt: CREATED_AT,
+ rotatedAt: null,
+ updatedAt: CREATED_AT,
+ ...overrides,
+ };
+}
+
+function createMockDb() {
+ const state = {
+ selectRows: [] as any[],
+ insertRows: [] as any[],
+ updateRows: [] as any[],
+ deleteWhere: [] as any[],
+ insertValues: [] as any[],
+ updateSet: undefined as any,
+ whereArgs: [] as any[],
+ auditValues: [] as any[],
+ storedCiphertext: '',
+ };
+
+ const db: any = {
+ select: vi.fn(() => ({
+ from: vi.fn(() => ({
+ where: vi.fn((whereArg: any) => {
+ state.whereArgs.push(whereArg);
+ return {
+ orderBy: vi.fn(() => Promise.resolve(state.selectRows)),
+ limit: vi.fn(() =>
+ Promise.resolve(
+ state.selectRows.length > 0
+ ? state.selectRows
+ : state.storedCiphertext
+ ? [{ ciphertext: state.storedCiphertext, sourcePms: 'mews' }]
+ : [],
+ ),
+ ),
+ then: (resolve: any, reject: any) =>
+ Promise.resolve(state.selectRows).then(resolve, reject),
+ };
+ }),
+ })),
+ })),
+ insert: vi.fn((table: unknown) => ({
+ values: vi.fn((values: any) => {
+ state.insertValues.push(values);
+ if ((table as any)?.__table === 'auditLogs') {
+ state.auditValues.push(values);
+ return Promise.resolve();
+ }
+ if (values.ciphertext) {
+ state.storedCiphertext = values.ciphertext;
+ }
+ return {
+ returning: vi.fn(() => Promise.resolve(state.insertRows)),
+ };
+ }),
+ })),
+ update: vi.fn(() => ({
+ set: vi.fn((values: any) => {
+ state.updateSet = values;
+ if (values.ciphertext) {
+ state.storedCiphertext = values.ciphertext;
+ }
+ return {
+ where: vi.fn((whereArg: any) => {
+ state.whereArgs.push(whereArg);
+ return {
+ returning: vi.fn(() => Promise.resolve(state.updateRows)),
+ };
+ }),
+ };
+ }),
+ })),
+ delete: vi.fn(() => ({
+ where: vi.fn((whereArg: any) => {
+ state.deleteWhere.push(whereArg);
+ return Promise.resolve();
+ }),
+ })),
+ };
+
+ return { db, state };
+}
+
+describe('MigrationSourceCredentialsService', () => {
+ let mock: ReturnType;
+ let service: MigrationSourceCredentialsService;
+ const originalEnv = { ...process.env };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ process.env = {
+ ...originalEnv,
+ MIGRATION_CREDENTIAL_ENCRYPTION_KEY: KEY_HEX,
+ MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID: 'default',
+ };
+ mock = createMockDb();
+ service = new MigrationSourceCredentialsService(mock.db);
+ });
+
+ it('lists metadata without ciphertext or decrypted values', async () => {
+ mock.state.selectRows = [metadataRow()];
+
+ const result = await service.listMetadata(PROP);
+
+ expect(result).toEqual([
+ {
+ id: CRED_ID,
+ propertyId: PROP,
+ sourcePms: 'mews',
+ encryptionKeyId: 'default',
+ createdAt: CREATED_AT,
+ rotatedAt: null,
+ updatedAt: CREATED_AT,
+ },
+ ]);
+ expect(result[0]).not.toHaveProperty('ciphertext');
+ expect(result[0]).not.toHaveProperty('credentials');
+ expect(mock.state.whereArgs[0]).toEqual({
+ op: 'eq',
+ column: 'cred.propertyId',
+ value: PROP,
+ });
+ });
+
+ it('encrypts credentials at rest and audits without secret values', async () => {
+ mock.state.selectRows = [];
+ mock.state.insertRows = [metadataRow()];
+
+ const result = await service.upsert(
+ PROP,
+ 'mews',
+ { clientToken: 'top-secret', accessToken: 'also-secret' },
+ { userId: 'user-1', userEmail: 'admin@example.com', ipAddress: '127.0.0.1' },
+ );
+
+ expect(result).not.toHaveProperty('credentials');
+ expect(result).not.toHaveProperty('ciphertext');
+ expect(mock.state.insertValues[0].ciphertext).not.toContain('top-secret');
+ expect(mock.state.insertValues[0].ciphertext).not.toContain('also-secret');
+ expect(mock.state.insertValues[0]).toMatchObject({
+ propertyId: PROP,
+ sourcePms: 'mews',
+ encryptionKeyId: 'default',
+ });
+ expect(mock.state.auditValues[0]).toMatchObject({
+ propertyId: PROP,
+ action: 'create',
+ entityType: 'migration_source_credential',
+ entityId: CRED_ID,
+ description: 'migration_credential.created',
+ });
+ expect(JSON.stringify(mock.state.auditValues[0].newValue)).not.toContain('top-secret');
+ expect(mock.state.auditValues[0].newValue).not.toHaveProperty('ciphertext');
+ });
+
+ it('round-trips credentials via decryptForRunner', async () => {
+ mock.state.selectRows = [];
+ mock.state.insertRows = [metadataRow()];
+ await service.upsert(PROP, 'mews', { apiKey: 'runner-secret' });
+
+ const decrypted = await service.decryptForRunner(PROP, 'mews');
+ expect(decrypted).toEqual({ apiKey: 'runner-secret' });
+ });
+
+ it('fails closed when decrypting tampered ciphertext', async () => {
+ mock.state.selectRows = [];
+ mock.state.insertRows = [metadataRow()];
+ await service.upsert(PROP, 'mews', { apiKey: 'runner-secret' });
+
+ const parsed = JSON.parse(mock.state.storedCiphertext);
+ parsed.authTag = '0'.repeat(parsed.authTag.length);
+ mock.state.storedCiphertext = JSON.stringify(parsed);
+ mock.state.selectRows = [{ ciphertext: mock.state.storedCiphertext, sourcePms: 'mews' }];
+
+ await expect(service.decryptForRunner(PROP, 'mews')).rejects.toBeInstanceOf(
+ BadRequestException,
+ );
+ });
+
+ it('rejects upsert when encryption key is not configured', async () => {
+ delete process.env['MIGRATION_CREDENTIAL_ENCRYPTION_KEY'];
+ await expect(
+ service.upsert(PROP, 'mews', { apiKey: 'x' }),
+ ).rejects.toBeInstanceOf(InternalServerErrorException);
+ });
+
+ it('deletes credentials scoped by property and source PMS', async () => {
+ mock.state.selectRows = [metadataRow()];
+
+ const result = await service.delete(PROP, 'mews', {
+ userId: 'user-1',
+ userEmail: 'admin@example.com',
+ ipAddress: '127.0.0.1',
+ });
+
+ expect(result).toEqual({ deleted: 1 });
+ expect(mock.state.deleteWhere[0]).toEqual({
+ op: 'and',
+ conditions: [
+ { op: 'eq', column: 'cred.propertyId', value: PROP },
+ { op: 'eq', column: 'cred.sourcePms', value: 'mews' },
+ ],
+ });
+ expect(mock.state.auditValues[0]).toMatchObject({
+ action: 'delete',
+ description: 'migration_credential.erased',
+ });
+ expect(mock.state.auditValues[0].newValue).not.toHaveProperty('ciphertext');
+ });
+
+ it('does not delete credentials from another tenant', async () => {
+ mock.state.selectRows = [];
+
+ await expect(service.delete(OTHER_PROP, 'mews')).rejects.toBeInstanceOf(NotFoundException);
+ expect(mock.state.deleteWhere).toEqual([]);
+ });
+
+ it('erases all credentials for a property when sourcePms is omitted', async () => {
+ mock.state.selectRows = [
+ metadataRow({ sourcePms: 'mews' }),
+ metadataRow({ id: '44444444-4444-4444-8444-444444444444', sourcePms: 'apaleo' }),
+ ];
+
+ const result = await service.delete(PROP);
+
+ expect(result).toEqual({ deleted: 2 });
+ expect(mock.state.auditValues).toHaveLength(2);
+ expect(mock.state.auditValues[0].description).toBe('migration_credential.erased_all');
+ });
+});
diff --git a/apps/api/src/modules/migration/migration-source-credentials.service.ts b/apps/api/src/modules/migration/migration-source-credentials.service.ts
new file mode 100644
index 00000000..d45be74c
--- /dev/null
+++ b/apps/api/src/modules/migration/migration-source-credentials.service.ts
@@ -0,0 +1,301 @@
+import {
+ BadRequestException,
+ Inject,
+ Injectable,
+ InternalServerErrorException,
+ Logger,
+ NotFoundException,
+} from '@nestjs/common';
+import { and, desc, eq } from 'drizzle-orm';
+import { auditLogs, migrationSourceCredentials } from '@telivityhaip/database';
+import type { MigrationSourcePms } from '@telivityhaip/shared';
+import {
+ CredentialEncryptionError,
+ decryptCredentialPlaintext,
+ deserializeEncryptedBlob,
+ encryptCredentialPlaintext,
+ loadMigrationCredentialKeyRingFromEnv,
+ serializeEncryptedBlob,
+} from '../../common/crypto/credential-encryption';
+import { actorFields, type AuditActor } from '../../common/audit/audit-actor';
+import { DRIZZLE } from '../../database/database.module';
+
+export interface MigrationCredentialMetadata {
+ id: string;
+ propertyId: string;
+ sourcePms: string;
+ encryptionKeyId: string;
+ createdAt: Date;
+ rotatedAt: Date | null;
+ updatedAt: Date;
+}
+
+/** Redacted audit payload — never includes credential values or ciphertext. */
+function auditMetadata(row: {
+ id: string;
+ propertyId: string;
+ sourcePms: string;
+ encryptionKeyId: string;
+ createdAt: Date;
+ rotatedAt: Date | null;
+ updatedAt: Date;
+}): MigrationCredentialMetadata {
+ return {
+ id: row.id,
+ propertyId: row.propertyId,
+ sourcePms: row.sourcePms,
+ encryptionKeyId: row.encryptionKeyId,
+ createdAt: row.createdAt,
+ rotatedAt: row.rotatedAt ?? null,
+ updatedAt: row.updatedAt,
+ };
+}
+
+/**
+ * Server-side vault for encrypted source-PMS migration credentials.
+ * Decrypted values are for the migration runner only — never exposed via HTTP.
+ */
+@Injectable()
+export class MigrationSourceCredentialsService {
+ private readonly logger = new Logger(MigrationSourceCredentialsService.name);
+
+ constructor(@Inject(DRIZZLE) private readonly db: any) {}
+
+ private keyRing() {
+ return loadMigrationCredentialKeyRingFromEnv();
+ }
+
+ async listMetadata(propertyId: string): Promise {
+ const rows = await this.db
+ .select({
+ id: migrationSourceCredentials.id,
+ propertyId: migrationSourceCredentials.propertyId,
+ sourcePms: migrationSourceCredentials.sourcePms,
+ encryptionKeyId: migrationSourceCredentials.encryptionKeyId,
+ createdAt: migrationSourceCredentials.createdAt,
+ rotatedAt: migrationSourceCredentials.rotatedAt,
+ updatedAt: migrationSourceCredentials.updatedAt,
+ })
+ .from(migrationSourceCredentials)
+ .where(eq(migrationSourceCredentials.propertyId, propertyId))
+ .orderBy(desc(migrationSourceCredentials.createdAt));
+
+ return rows.map((row: MigrationCredentialMetadata) => auditMetadata(row));
+ }
+
+ async upsert(
+ propertyId: string,
+ sourcePms: MigrationSourcePms,
+ credentials: Record,
+ actor?: AuditActor,
+ ): Promise {
+ let blob;
+ try {
+ blob = encryptCredentialPlaintext(JSON.stringify(credentials), this.keyRing());
+ } catch (err) {
+ if (err instanceof CredentialEncryptionError) {
+ throw new InternalServerErrorException(err.message);
+ }
+ throw err;
+ }
+
+ const serialized = serializeEncryptedBlob(blob);
+ const now = new Date();
+ const [existing] = await this.db
+ .select({ id: migrationSourceCredentials.id })
+ .from(migrationSourceCredentials)
+ .where(
+ and(
+ eq(migrationSourceCredentials.propertyId, propertyId),
+ eq(migrationSourceCredentials.sourcePms, sourcePms),
+ ),
+ )
+ .limit(1);
+
+ let row: {
+ id: string;
+ propertyId: string;
+ sourcePms: string;
+ encryptionKeyId: string;
+ createdAt: Date;
+ rotatedAt: Date | null;
+ updatedAt: Date;
+ };
+
+ if (existing) {
+ [row] = await this.db
+ .update(migrationSourceCredentials)
+ .set({
+ ciphertext: serialized,
+ encryptionKeyId: blob.keyId,
+ rotatedAt: now,
+ updatedAt: now,
+ })
+ .where(
+ and(
+ eq(migrationSourceCredentials.id, existing.id),
+ eq(migrationSourceCredentials.propertyId, propertyId),
+ ),
+ )
+ .returning({
+ id: migrationSourceCredentials.id,
+ propertyId: migrationSourceCredentials.propertyId,
+ sourcePms: migrationSourceCredentials.sourcePms,
+ encryptionKeyId: migrationSourceCredentials.encryptionKeyId,
+ createdAt: migrationSourceCredentials.createdAt,
+ rotatedAt: migrationSourceCredentials.rotatedAt,
+ updatedAt: migrationSourceCredentials.updatedAt,
+ });
+ await this.writeAudit('update', propertyId, row.id, 'migration_credential.rotated', row, actor);
+ } else {
+ [row] = await this.db
+ .insert(migrationSourceCredentials)
+ .values({
+ propertyId,
+ sourcePms,
+ ciphertext: serialized,
+ encryptionKeyId: blob.keyId,
+ })
+ .returning({
+ id: migrationSourceCredentials.id,
+ propertyId: migrationSourceCredentials.propertyId,
+ sourcePms: migrationSourceCredentials.sourcePms,
+ encryptionKeyId: migrationSourceCredentials.encryptionKeyId,
+ createdAt: migrationSourceCredentials.createdAt,
+ rotatedAt: migrationSourceCredentials.rotatedAt,
+ updatedAt: migrationSourceCredentials.updatedAt,
+ });
+ await this.writeAudit('create', propertyId, row.id, 'migration_credential.created', row, actor);
+ }
+
+ return auditMetadata(row);
+ }
+
+ /**
+ * Decrypt credentials for the migration runner. Must never be called from a
+ * controller response path.
+ */
+ async decryptForRunner(
+ propertyId: string,
+ sourcePms: MigrationSourcePms,
+ ): Promise> {
+ const [row] = await this.db
+ .select({
+ ciphertext: migrationSourceCredentials.ciphertext,
+ sourcePms: migrationSourceCredentials.sourcePms,
+ })
+ .from(migrationSourceCredentials)
+ .where(
+ and(
+ eq(migrationSourceCredentials.propertyId, propertyId),
+ eq(migrationSourceCredentials.sourcePms, sourcePms),
+ ),
+ )
+ .limit(1);
+
+ if (!row) {
+ throw new NotFoundException(
+ `Migration credentials for source PMS "${sourcePms}" not found`,
+ );
+ }
+
+ try {
+ const blob = deserializeEncryptedBlob(row.ciphertext);
+ const plaintext = decryptCredentialPlaintext(blob, this.keyRing());
+ const parsed: unknown = JSON.parse(plaintext);
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw new CredentialEncryptionError('Stored credentials are not a JSON object');
+ }
+ return parsed as Record;
+ } catch (err) {
+ this.logger.warn(
+ `Failed to decrypt migration credentials for property=${propertyId} sourcePms=${sourcePms}`,
+ );
+ if (err instanceof CredentialEncryptionError) {
+ throw new BadRequestException(err.message);
+ }
+ throw err;
+ }
+ }
+
+ /**
+ * Delete stored credentials — invoked on migration project completion or GDPR erasure.
+ * When sourcePms is omitted, all credentials for the property are removed.
+ */
+ async delete(
+ propertyId: string,
+ sourcePms?: MigrationSourcePms,
+ actor?: AuditActor,
+ ): Promise<{ deleted: number }> {
+ const conditions = [eq(migrationSourceCredentials.propertyId, propertyId)];
+ if (sourcePms) {
+ conditions.push(eq(migrationSourceCredentials.sourcePms, sourcePms));
+ }
+
+ const rows = await this.db
+ .select({
+ id: migrationSourceCredentials.id,
+ propertyId: migrationSourceCredentials.propertyId,
+ sourcePms: migrationSourceCredentials.sourcePms,
+ encryptionKeyId: migrationSourceCredentials.encryptionKeyId,
+ createdAt: migrationSourceCredentials.createdAt,
+ rotatedAt: migrationSourceCredentials.rotatedAt,
+ updatedAt: migrationSourceCredentials.updatedAt,
+ })
+ .from(migrationSourceCredentials)
+ .where(and(...conditions));
+
+ if (rows.length === 0) {
+ if (sourcePms) {
+ throw new NotFoundException(
+ `Migration credentials for source PMS "${sourcePms}" not found`,
+ );
+ }
+ return { deleted: 0 };
+ }
+
+ await this.db
+ .delete(migrationSourceCredentials)
+ .where(and(...conditions));
+
+ for (const row of rows) {
+ await this.writeAudit(
+ 'delete',
+ propertyId,
+ row.id,
+ sourcePms ? 'migration_credential.erased' : 'migration_credential.erased_all',
+ row,
+ actor,
+ );
+ }
+
+ return { deleted: rows.length };
+ }
+
+ private async writeAudit(
+ action: 'create' | 'update' | 'delete',
+ propertyId: string,
+ entityId: string,
+ description: string,
+ row: {
+ id: string;
+ propertyId: string;
+ sourcePms: string;
+ encryptionKeyId: string;
+ createdAt: Date;
+ rotatedAt: Date | null;
+ updatedAt: Date;
+ },
+ actor?: AuditActor,
+ ) {
+ await this.db.insert(auditLogs).values({
+ propertyId,
+ action,
+ entityType: 'migration_source_credential',
+ entityId,
+ description,
+ newValue: auditMetadata(row),
+ ...actorFields(actor),
+ });
+ }
+}
diff --git a/apps/api/src/modules/migration/migration.controller.spec.ts b/apps/api/src/modules/migration/migration.controller.spec.ts
new file mode 100644
index 00000000..40ad2499
--- /dev/null
+++ b/apps/api/src/modules/migration/migration.controller.spec.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+import { PERMISSIONS_KEY } from '../auth/permissions.decorator';
+import { MigrationController } from './migration.controller';
+
+describe('MigrationController authorization', () => {
+ it('requires settings.manage on credential routes', () => {
+ const listPerms = Reflect.getMetadata(
+ PERMISSIONS_KEY,
+ MigrationController.prototype.listCredentials,
+ );
+ const upsertPerms = Reflect.getMetadata(
+ PERMISSIONS_KEY,
+ MigrationController.prototype.upsertCredentials,
+ );
+ const deletePerms = Reflect.getMetadata(
+ PERMISSIONS_KEY,
+ MigrationController.prototype.deleteCredential,
+ );
+ const deleteAllPerms = Reflect.getMetadata(
+ PERMISSIONS_KEY,
+ MigrationController.prototype.deleteAllCredentials,
+ );
+
+ expect(listPerms).toEqual(['settings.manage']);
+ expect(upsertPerms).toEqual(['settings.manage']);
+ expect(deletePerms).toEqual(['settings.manage']);
+ expect(deleteAllPerms).toEqual(['settings.manage']);
+ });
+});
diff --git a/apps/api/src/modules/migration/migration.controller.ts b/apps/api/src/modules/migration/migration.controller.ts
new file mode 100644
index 00000000..8eca34a4
--- /dev/null
+++ b/apps/api/src/modules/migration/migration.controller.ts
@@ -0,0 +1,86 @@
+import {
+ BadRequestException,
+ Body,
+ Controller,
+ Delete,
+ Get,
+ Param,
+ Post,
+ Query,
+ ParseUUIDPipe,
+} from '@nestjs/common';
+import { ApiOperation, ApiTags } from '@nestjs/swagger';
+import { MIGRATION_SOURCE_PMS } from '@telivityhaip/shared';
+import { Roles } from '../auth/roles.decorator';
+import { RequirePermissions } from '../auth/permissions.decorator';
+import { AuditActorCtx, type AuditActor } from '../../common/audit/audit-actor';
+import { MigrationSourceCredentialsService } from './migration-source-credentials.service';
+import { UpsertMigrationCredentialDto } from './dto/upsert-migration-credential.dto';
+
+/**
+ * Automated PMS migration — encrypted source credential vault.
+ * Credentials are encrypted at rest; API responses never include plaintext secrets.
+ */
+@ApiTags('migration')
+@Controller('migration')
+@Roles('admin')
+export class MigrationController {
+ constructor(private readonly credentials: MigrationSourceCredentialsService) {}
+
+ @Get('credentials')
+ @RequirePermissions('settings.manage')
+ @ApiOperation({
+ summary: 'List stored source-PMS credential metadata (no secret values)',
+ })
+ listCredentials(@Query('propertyId', new ParseUUIDPipe()) propertyId: string) {
+ return this.credentials.listMetadata(propertyId);
+ }
+
+ @Post('credentials')
+ @RequirePermissions('settings.manage')
+ @ApiOperation({
+ summary: 'Store or rotate encrypted source-PMS credentials for migration',
+ })
+ upsertCredentials(
+ @Query('propertyId', new ParseUUIDPipe()) propertyId: string,
+ @Body() dto: UpsertMigrationCredentialDto,
+ @AuditActorCtx() actor: AuditActor,
+ ) {
+ return this.credentials.upsert(propertyId, dto.sourcePms, dto.credentials, actor);
+ }
+
+ @Delete('credentials/:sourcePms')
+ @RequirePermissions('settings.manage')
+ @ApiOperation({
+ summary:
+ 'Erase encrypted source-PMS credentials (migration completion or GDPR erasure)',
+ })
+ deleteCredential(
+ @Query('propertyId', new ParseUUIDPipe()) propertyId: string,
+ @Param('sourcePms') sourcePms: string,
+ @AuditActorCtx() actor: AuditActor,
+ ) {
+ if (!MIGRATION_SOURCE_PMS.includes(sourcePms as (typeof MIGRATION_SOURCE_PMS)[number])) {
+ throw new BadRequestException(
+ `sourcePms must be one of: ${MIGRATION_SOURCE_PMS.join(', ')}`,
+ );
+ }
+ return this.credentials.delete(
+ propertyId,
+ sourcePms as (typeof MIGRATION_SOURCE_PMS)[number],
+ actor,
+ );
+ }
+
+ @Delete('credentials')
+ @RequirePermissions('settings.manage')
+ @ApiOperation({
+ summary: 'Erase all encrypted source-PMS credentials for a property',
+ })
+ deleteAllCredentials(
+ @Query('propertyId', new ParseUUIDPipe()) propertyId: string,
+ @AuditActorCtx() actor: AuditActor,
+ ) {
+ return this.credentials.delete(propertyId, undefined, actor);
+ }
+}
diff --git a/apps/api/src/modules/migration/migration.module.ts b/apps/api/src/modules/migration/migration.module.ts
new file mode 100644
index 00000000..0d87aff3
--- /dev/null
+++ b/apps/api/src/modules/migration/migration.module.ts
@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { AuthModule } from '../auth/auth.module';
+import { MigrationController } from './migration.controller';
+import { MigrationSourceCredentialsService } from './migration-source-credentials.service';
+
+@Module({
+ imports: [AuthModule],
+ controllers: [MigrationController],
+ providers: [MigrationSourceCredentialsService],
+ exports: [MigrationSourceCredentialsService],
+})
+export class MigrationModule {}
diff --git a/packages/database/src/migrations/0019_migration_source_credentials.sql b/packages/database/src/migrations/0019_migration_source_credentials.sql
new file mode 100644
index 00000000..2bbe3689
--- /dev/null
+++ b/packages/database/src/migrations/0019_migration_source_credentials.sql
@@ -0,0 +1,17 @@
+-- Encrypted source-PMS credentials for automated migration connectors.
+CREATE TABLE IF NOT EXISTS migration_source_credentials (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ property_id uuid NOT NULL REFERENCES properties(id),
+ source_pms varchar(50) NOT NULL,
+ ciphertext text NOT NULL,
+ encryption_key_id varchar(50) NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ rotated_at timestamptz,
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS migration_source_credentials_property_source_unique
+ ON migration_source_credentials (property_id, source_pms);
+
+CREATE INDEX IF NOT EXISTS migration_source_credentials_property_idx
+ ON migration_source_credentials (property_id);
diff --git a/packages/database/src/push-schema.ts b/packages/database/src/push-schema.ts
index a0ff6ff0..bb91858d 100644
--- a/packages/database/src/push-schema.ts
+++ b/packages/database/src/push-schema.ts
@@ -1308,6 +1308,26 @@ async function main() {
CREATE INDEX IF NOT EXISTS door_lock_credentials_property_status_idx
ON door_lock_credentials (property_id, status)`));
+ await db.execute(sql.raw(`
+ CREATE TABLE IF NOT EXISTS migration_source_credentials (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ property_id uuid NOT NULL REFERENCES properties(id),
+ source_pms varchar(50) NOT NULL,
+ ciphertext text NOT NULL,
+ encryption_key_id varchar(50) NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ rotated_at timestamptz,
+ updated_at timestamptz NOT NULL DEFAULT now()
+ )`));
+
+ await db.execute(sql.raw(`
+ CREATE UNIQUE INDEX IF NOT EXISTS migration_source_credentials_property_source_unique
+ ON migration_source_credentials (property_id, source_pms)`));
+
+ await db.execute(sql.raw(`
+ CREATE INDEX IF NOT EXISTS migration_source_credentials_property_idx
+ ON migration_source_credentials (property_id)`));
+
// iCal calendar bridge (.ics import/export) — availability subtracts ical_blocks
await db.execute(sql.raw(`
DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'ical_feed_direction') THEN
diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts
index 7f364370..0cb14ca2 100644
--- a/packages/database/src/schema/index.ts
+++ b/packages/database/src/schema/index.ts
@@ -261,3 +261,6 @@ export {
icalFeeds,
icalBlocks,
} from './ical.js';
+
+// Automated PMS migration — encrypted source credentials vault
+export { migrationSourceCredentials } from './migration.js';
diff --git a/packages/database/src/schema/migration.ts b/packages/database/src/schema/migration.ts
new file mode 100644
index 00000000..dce7dff0
--- /dev/null
+++ b/packages/database/src/schema/migration.ts
@@ -0,0 +1,32 @@
+import { pgTable, uuid, varchar, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
+import { properties } from './property.js';
+
+/**
+ * Encrypted source-PMS API credentials for automated migration (Mews, Cloudbeds,
+ * Apaleo, OHIP). Plaintext secrets are AES-256-GCM encrypted at the app layer
+ * before persistence — never stored in jsonb like channel_connections.config.
+ *
+ * Decrypted values are server-side only (migration runner); API responses expose
+ * metadata only.
+ */
+export const migrationSourceCredentials = pgTable(
+ 'migration_source_credentials',
+ {
+ id: uuid('id').primaryKey().defaultRandom(),
+ propertyId: uuid('property_id').notNull().references(() => properties.id),
+ /** Source PMS identifier, e.g. mews | cloudbeds | apaleo | ohip */
+ sourcePms: varchar('source_pms', { length: 50 }).notNull(),
+ /** JSON-serialized EncryptedCredentialBlob (iv + ciphertext + authTag + keyId) */
+ ciphertext: text('ciphertext').notNull(),
+ encryptionKeyId: varchar('encryption_key_id', { length: 50 }).notNull(),
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
+ rotatedAt: timestamp('rotated_at', { withTimezone: true }),
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
+ },
+ (table) => ({
+ propertySourceUnique: uniqueIndex('migration_source_credentials_property_source_unique').on(
+ table.propertyId,
+ table.sourcePms,
+ ),
+ }),
+);
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 38337ba8..8692fa25 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -273,3 +273,7 @@ export function checkFnrhComplete(
/** Legacy alias for checkFnrhComplete */
export const isFnrhComplete = checkFnrhComplete;
+/** Tier-1 source PMS identifiers for automated migration connectors. */
+export const MIGRATION_SOURCE_PMS = ['mews', 'cloudbeds', 'apaleo', 'ohip'] as const;
+export type MigrationSourcePms = (typeof MIGRATION_SOURCE_PMS)[number];
+
From 4b470f3b399de9d01b3cf3ed46c3eee361d94d1f Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Tue, 11 Aug 2026 01:03:05 +0000
Subject: [PATCH 2/2] chore: sync README test counts after migration credential
vault tests
Co-authored-by: telivity-otaip
---
README.md | 8 ++++----
docs/test-stats.json | 6 +++---
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index 23981f1f..523c75bd 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 (1458 tests across 206 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |
+| Testing | Vitest (1474 tests across 209 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 (1458 tests across 206 test files)
+# All tests (1474 tests across 209 test files)
# API tests only
pnpm --filter @telivityhaip/api test
@@ -1188,7 +1188,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 (1458 tests, 206 files)
+pnpm test # Run all tests (1474 tests, 209 files)
pnpm lint # ESLint
```
diff --git a/docs/test-stats.json b/docs/test-stats.json
index 08bef479..550998b0 100644
--- a/docs/test-stats.json
+++ b/docs/test-stats.json
@@ -1,5 +1,5 @@
{
- "tests": 1458,
- "files": 206,
- "updatedAt": "2026-08-09T19:14:16.625Z"
+ "tests": 1474,
+ "files": 209,
+ "updatedAt": "2026-08-11T01:02:26.116Z"
}