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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,17 @@ KEYCLOAK_URL=http://localhost:8080
# GOOGLE_WALLET_SERVICE_ACCOUNT_PRIVATE_KEY=
# Door lock vendor (Nuki / TTLock / Salto KS / webhook / console). Per-room
# rooms.external_lock_id overrides the env default lock/site id.
# In production/staging, missing vendors fail closed (no console PIN fallback).
# DOOR_LOCK_PROVIDER=console
# NUKI_API_TOKEN=
# NUKI_SMARTLOCK_ID=
# AES-256 key (64 hex chars) for encrypting guest ID photos / ID numbers at rest.
# Required in production/staging (assertSecureConfig).
# ID_ENCRYPTION_KEY=
# LOCAL_STORAGE_PATH=./data/uploads
# Tight rate limit for POST /guest-check-in/request-link (default 10/min).
# CHECK_IN_RATE_LIMIT_MAX=10
# CHECK_IN_RATE_LIMIT_WINDOW_MS=60000

# Connect API (OTAIP agents) — comma-separated list of valid API keys.
# When AUTH_ENABLED != 'false' and this is unset/empty, ConnectController
Expand Down
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-2584%20passing-brightgreen" alt="2584 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-2599%20passing-brightgreen" alt="2599 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 (2584 passing tests across 301 files with passing tests) | Unit and integration tests |
| Testing | Vitest (2599 passing tests across 302 files with passing tests) | 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 @@ -648,7 +648,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment.
### Run tests

```bash
# Passing-test count: 2584 test cases across 301 files (skipped excluded)
# Passing-test count: 2599 test cases across 302 files (skipped excluded)

# API tests only
pnpm --filter @telivityhaip/api test
Expand Down Expand Up @@ -1197,7 +1197,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 (2584 passing, 301 files with passes; skipped excluded)
pnpm test # Run all tests (2599 passing, 302 files with passes; skipped excluded)
pnpm lint # ESLint
```

Expand Down
16 changes: 15 additions & 1 deletion apps/api/src/common/config/assert-secure-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ describe('assertSecureConfig', () => {
AUTH_ENABLED: 'true',
STRIPE_MODE: 'mock',
PAYMENT_GATEWAY: 'adyen',
ID_ENCRYPTION_KEY: 'a'.repeat(64),
} as any),
).not.toThrow();
});
Expand All @@ -35,7 +36,20 @@ describe('assertSecureConfig', () => {
).not.toThrow();
});

it('refuses to boot in production without ID_ENCRYPTION_KEY', () => {
expect(() =>
assertSecureConfig({ NODE_ENV: 'production', AUTH_ENABLED: 'true', STRIPE_MODE: 'live' } as any),
).toThrow(/ID_ENCRYPTION_KEY/);
});

it('passes a properly-configured production', () => {
expect(() => assertSecureConfig({ NODE_ENV: 'production', AUTH_ENABLED: 'true', STRIPE_MODE: 'live' } as any)).not.toThrow();
expect(() =>
assertSecureConfig({
NODE_ENV: 'production',
AUTH_ENABLED: 'true',
STRIPE_MODE: 'live',
ID_ENCRYPTION_KEY: 'a'.repeat(64),
} as any),
).not.toThrow();
});
});
3 changes: 3 additions & 0 deletions apps/api/src/common/config/assert-secure-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export function assertSecureConfig(env: NodeJS.ProcessEnv = process.env): void {
if (isPaymentGatewayMockMode(env)) {
problems.push('PAYMENT_GATEWAY=mock (or STRIPE_MODE=mock when PAYMENT_GATEWAY unset)');
}
if (!env['ID_ENCRYPTION_KEY']?.trim()) {
problems.push('ID_ENCRYPTION_KEY is unset (required to encrypt guest ID photos at rest)');
}
if (problems.length > 0) {
throw new Error(
`Refusing to start in production with insecure config: ${problems.join(', ')}. ` +
Expand Down
48 changes: 43 additions & 5 deletions apps/api/src/modules/door-lock/lock-provider.factory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,22 @@ function stubProvider(name: string, configured: boolean): LockProvider {
}

describe('LockProviderFactory', () => {
const prev = process.env['DOOR_LOCK_PROVIDER'];
const prevProvider = process.env['DOOR_LOCK_PROVIDER'];
const prevEnv = process.env['NODE_ENV'];
const prevInsecure = process.env['HAIP_ALLOW_INSECURE'];

afterEach(() => {
if (prev === undefined) delete process.env['DOOR_LOCK_PROVIDER'];
else process.env['DOOR_LOCK_PROVIDER'] = prev;
if (prevProvider === undefined) delete process.env['DOOR_LOCK_PROVIDER'];
else process.env['DOOR_LOCK_PROVIDER'] = prevProvider;
if (prevEnv === undefined) delete process.env['NODE_ENV'];
else process.env['NODE_ENV'] = prevEnv;
if (prevInsecure === undefined) delete process.env['HAIP_ALLOW_INSECURE'];
else process.env['HAIP_ALLOW_INSECURE'] = prevInsecure;
});

it('defaults to webhook when configured', () => {
delete process.env['DOOR_LOCK_PROVIDER'];
process.env['NODE_ENV'] = 'test';
const webhook = stubProvider('webhook', true);
const consoleProvider = stubProvider('console', true);
const factory = new LockProviderFactory([webhook, consoleProvider]);
Expand All @@ -29,22 +36,53 @@ describe('LockProviderFactory', () => {

it('selects nuki when configured', () => {
process.env['DOOR_LOCK_PROVIDER'] = 'nuki';
process.env['NODE_ENV'] = 'test';
const nuki = stubProvider('nuki', true);
const consoleProvider = stubProvider('console', true);
const factory = new LockProviderFactory([nuki, consoleProvider]);
expect(factory.resolve()).toBe(nuki);
});

it('falls back to console when vendor is not configured', () => {
it('falls back to console when vendor is not configured in non-production', () => {
process.env['DOOR_LOCK_PROVIDER'] = 'ttlock';
process.env['NODE_ENV'] = 'development';
const ttlock = stubProvider('ttlock', false);
const consoleProvider = stubProvider('console', true);
const factory = new LockProviderFactory([ttlock, consoleProvider]);
expect(factory.resolve()).toBe(consoleProvider);
});

it('falls back to console for unknown provider names', () => {
it('falls back to console for unknown provider names in non-production', () => {
process.env['DOOR_LOCK_PROVIDER'] = 'unknown-vendor';
process.env['NODE_ENV'] = 'test';
const consoleProvider = stubProvider('console', true);
const factory = new LockProviderFactory([consoleProvider]);
expect(factory.resolve()).toBe(consoleProvider);
});

it('fails closed in production when vendor is not configured', () => {
process.env['DOOR_LOCK_PROVIDER'] = 'nuki';
process.env['NODE_ENV'] = 'production';
delete process.env['HAIP_ALLOW_INSECURE'];
const nuki = stubProvider('nuki', false);
const consoleProvider = stubProvider('console', true);
const factory = new LockProviderFactory([nuki, consoleProvider]);
expect(() => factory.resolve()).toThrow(/not configured|refusing console/i);
});

it('refuses explicit console provider in production', () => {
process.env['DOOR_LOCK_PROVIDER'] = 'console';
process.env['NODE_ENV'] = 'production';
delete process.env['HAIP_ALLOW_INSECURE'];
const consoleProvider = stubProvider('console', true);
const factory = new LockProviderFactory([consoleProvider]);
expect(() => factory.resolve()).toThrow(/not allowed in production/i);
});

it('allows console in production when HAIP_ALLOW_INSECURE=true', () => {
process.env['DOOR_LOCK_PROVIDER'] = 'console';
process.env['NODE_ENV'] = 'production';
process.env['HAIP_ALLOW_INSECURE'] = 'true';
const consoleProvider = stubProvider('console', true);
const factory = new LockProviderFactory([consoleProvider]);
expect(factory.resolve()).toBe(consoleProvider);
Expand Down
43 changes: 37 additions & 6 deletions apps/api/src/modules/door-lock/lock-provider.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { LOCK_PROVIDERS, type LockProvider } from './lock-provider.interface';
* Selects the active door-lock adapter from env and registered providers.
*
* `DOOR_LOCK_PROVIDER` names the adapter (`webhook`, `nuki`, `ttlock`, `salto_ks`, `console`).
* When the requested vendor is not configured, falls back to `console` (logged-only PIN).
* In production/staging (without HAIP_ALLOW_INSECURE), missing vendors fail closed —
* no silent console PIN fallback. Dev/test may fall back to console for local demos.
*/
@Injectable()
export class LockProviderFactory {
Expand Down Expand Up @@ -33,26 +34,56 @@ export class LockProviderFactory {
return this.resolveNamed(name);
}

/** True when console fallback / explicit console must be refused. */
private failClosed(): boolean {
const nodeEnv = process.env['NODE_ENV'];
const productionLike = nodeEnv === 'production' || nodeEnv === 'staging';
if (!productionLike) return false;
return process.env['HAIP_ALLOW_INSECURE'] !== 'true';
}

private resolveNamed(requestedRaw: string): LockProvider {
const requested = requestedRaw.replace(/-/g, '_');
const byName = new Map(this.providers.map((p) => [p.name, p]));
const chosen = byName.get(requested);

if (chosen?.isConfigured()) {
if (requested === 'console' && this.failClosed()) {
throw new Error('Console door lock provider is not allowed in production');
}
this.logger.log(`Door lock provider: ${chosen.name}`);
return chosen;
}

if (requested === 'console') {
const consoleProvider = byName.get('console');
if (!consoleProvider) throw new Error('Console lock provider not registered');
if (this.failClosed()) {
throw new Error('Console door lock provider is not allowed in production');
}
this.logger.warn('Using console door lock provider (non-production)');
return consoleProvider;
}

if (chosen && !chosen.isConfigured()) {
this.logger.warn(
`Door lock provider "${requested}" is not configured — falling back to console adapter`,
);
this.logger.warn(`Door lock provider "${requested}" is not configured`);
} else if (!chosen) {
this.logger.warn(`Unknown door lock provider "${requested}" — falling back to console adapter`);
this.logger.warn(`Unknown door lock provider "${requested}"`);
}

if (this.failClosed()) {
throw new Error(
`Door lock provider "${requested}" is not configured — refusing console fallback in production`,
);
}

const consoleProvider = byName.get('console');
if (consoleProvider) return consoleProvider;
if (consoleProvider) {
this.logger.warn(
`Falling back to console adapter for "${requested}" (non-production only)`,
);
return consoleProvider;
}

const webhook = byName.get('webhook');
if (webhook) return webhook;
Expand Down
33 changes: 33 additions & 0 deletions apps/api/src/modules/guest-check-in/check-in-magic.guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,43 @@ describe('CheckInMagicGuard', () => {
'other-secret',
);
const req: any = {
method: 'GET',
headers: { 'x-check-in-token': good },
query: { token: bad },
};
expect(guard.canActivate(ctx(req))).toBe(true);
expect(req.checkIn.reservationId).toBe('from-header');
});

it('accepts query token on GET for magic-link bootstrap', () => {
const guard = new CheckInMagicGuard(config);
const token = signCheckInToken(
{
v: 1,
reservationId: 'r1',
propertyId: 'p1',
exp: Math.floor(Date.now() / 1000) + 3600,
},
secret,
);
const req: any = { method: 'GET', headers: {}, query: { token } };
expect(guard.canActivate(ctx(req))).toBe(true);
expect(req.checkIn.reservationId).toBe('r1');
});

it('rejects query token on mutating POST', () => {
const guard = new CheckInMagicGuard(config);
const token = signCheckInToken(
{
v: 1,
reservationId: 'r1',
propertyId: 'p1',
exp: Math.floor(Date.now() / 1000) + 3600,
},
secret,
);
expect(() =>
guard.canActivate(ctx({ method: 'POST', headers: {}, query: { token } })),
).toThrow(UnauthorizedException);
});
});
9 changes: 7 additions & 2 deletions apps/api/src/modules/guest-check-in/check-in-magic.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,13 @@ export class CheckInMagicGuard implements CanActivate {
if (typeof auth === 'string' && auth.toLowerCase().startsWith('bearer ')) {
return auth.slice(7).trim();
}
const q = req.query?.token;
if (typeof q === 'string' && q.length > 0) return q;
// Query tokens are bootstrap-only (magic link / payment return). Mutating
// methods must use the header after the booking UI strips ?token=.
const method = String(req.method || 'GET').toUpperCase();
if (method === 'GET') {
const q = req.query?.token;
if (typeof q === 'string' && q.length > 0) return q;
}
return undefined;
}
}
71 changes: 71 additions & 0 deletions apps/api/src/modules/guest-check-in/check-in-throttle.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {
Injectable,
CanActivate,
ExecutionContext,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

/**
* Tight per-route rate limiter for public check-in link re-request
* (`POST /guest-check-in/request-link`). Keyed by IP + propertyId.
*
* Tunable: CHECK_IN_RATE_LIMIT_MAX (default 10) per CHECK_IN_RATE_LIMIT_WINDOW_MS
* (default 60000). Disabled when NODE_ENV=test or RATE_LIMIT_DISABLED=true.
*/
@Injectable()
export class CheckInThrottleGuard implements CanActivate {
private readonly hits = new Map<string, { count: number; resetAt: number }>();
private readonly max: number;
private readonly windowMs: number;
private readonly disabled: boolean;
private readonly trustProxy: boolean;

constructor(private readonly configService: ConfigService) {
this.max = Number(this.configService.get('CHECK_IN_RATE_LIMIT_MAX', '10')) || 10;
this.windowMs =
Number(this.configService.get('CHECK_IN_RATE_LIMIT_WINDOW_MS', '60000')) || 60000;
this.disabled =
process.env['NODE_ENV'] === 'test' ||
this.configService.get<string>('RATE_LIMIT_DISABLED', 'false') === 'true';
this.trustProxy =
this.configService.get<string>('RATE_LIMIT_TRUST_PROXY', 'false') === 'true';
}

canActivate(context: ExecutionContext): boolean {
if (this.disabled) return true;
const req = context.switchToHttp().getRequest();
if (!req) return true;

const propertyId = req.body?.propertyId ?? 'unknown';
const key = `${this.clientIp(req)}:${propertyId}`;
const now = Date.now();
const entry = this.hits.get(key);
if (!entry || now >= entry.resetAt) {
this.hits.set(key, { count: 1, resetAt: now + this.windowMs });
this.sweep(now);
return true;
}
entry.count++;
if (entry.count > this.max) {
throw new HttpException('Too many check-in link requests', HttpStatus.TOO_MANY_REQUESTS);
}
return true;
}

private clientIp(req: any): string {
if (this.trustProxy) {
const fwd = req.headers?.['x-forwarded-for'];
if (typeof fwd === 'string' && fwd.length > 0) return fwd.split(',')[0]!.trim();
}
return req.ip ?? req.socket?.remoteAddress ?? 'unknown';
}

private sweep(now: number): void {
if (this.hits.size < 10_000) return;
for (const [k, v] of this.hits) {
if (now >= v.resetAt) this.hits.delete(k);
}
}
}
Loading
Loading