From 61a4f27af4527e44abafb76e35357ae825f9eb21 Mon Sep 17 00:00:00 2001 From: olsch01 Date: Wed, 11 Mar 2026 15:22:58 -0400 Subject: [PATCH 1/2] security: address assessment findings and bump to v2026.3.11 - C1: Disable Swagger UI in production (env gate) - M1+M2: Add Helmet.js for security headers (CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy) and remove X-Powered-By - H2: Add @nestjs/throttler rate limiting (5 req/min on login/register) - M4: Remove orgSchema from JWT payload and client-side storage; tenant middleware now resolves schema from orgId via cached DB lookup - L1: Fix Chatwoot user identification (read from auth store on ready) - Remove schemaName from frontend Organization type and UI displays Co-Authored-By: Claude Opus 4.6 --- backend/package-lock.json | 22 +++++++++++ backend/package.json | 6 ++- backend/src/app.module.ts | 5 +++ backend/src/database/tenant.middleware.ts | 38 ++++++++++-------- backend/src/main.ts | 39 ++++++++++++++----- backend/src/modules/auth/auth.controller.ts | 3 ++ backend/src/modules/auth/auth.service.ts | 3 -- .../modules/auth/strategies/jwt.strategy.ts | 1 - .../health-scores/health-scores.controller.ts | 8 ++-- frontend/index.html | 14 +++++++ frontend/package.json | 2 +- frontend/src/pages/auth/SelectOrgPage.tsx | 5 --- frontend/src/pages/settings/SettingsPage.tsx | 4 -- frontend/src/stores/authStore.ts | 1 - 14 files changed, 105 insertions(+), 46 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 0bbf241..d3ba965 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -16,10 +16,12 @@ "@nestjs/platform-express": "^10.4.15", "@nestjs/schedule": "^6.1.1", "@nestjs/swagger": "^7.4.2", + "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^10.0.2", "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "helmet": "^8.1.0", "ioredis": "^5.4.2", "newrelic": "latest", "passport": "^0.7.0", @@ -1791,6 +1793,17 @@ } } }, + "node_modules/@nestjs/throttler": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz", + "integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, "node_modules/@nestjs/typeorm": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz", @@ -5277,6 +5290,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/html-entities": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", diff --git a/backend/package.json b/backend/package.json index 459ae78..9244111 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "hoa-ledgeriq-backend", - "version": "2026.03.10", + "version": "2026.3.11", "description": "HOA LedgerIQ - Backend API", "private": true, "scripts": { @@ -25,11 +25,14 @@ "@nestjs/platform-express": "^10.4.15", "@nestjs/schedule": "^6.1.1", "@nestjs/swagger": "^7.4.2", + "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^10.0.2", "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "helmet": "^8.1.0", "ioredis": "^5.4.2", + "newrelic": "latest", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", @@ -37,7 +40,6 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "typeorm": "^0.3.20", - "newrelic": "latest", "uuid": "^9.0.1" }, "devDependencies": { diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 6c87411..e7e2a09 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -2,6 +2,7 @@ import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ThrottlerModule } from '@nestjs/throttler'; import { AppController } from './app.controller'; import { DatabaseModule } from './database/database.module'; import { TenantMiddleware } from './database/tenant.middleware'; @@ -52,6 +53,10 @@ import { ScheduleModule } from '@nestjs/schedule'; }, }), }), + ThrottlerModule.forRoot([{ + ttl: 60000, // 1-minute window + limit: 100, // 100 requests per minute (global default) + }]), DatabaseModule, AuthModule, OrganizationsModule, diff --git a/backend/src/database/tenant.middleware.ts b/backend/src/database/tenant.middleware.ts index 5a489c1..ea6c960 100644 --- a/backend/src/database/tenant.middleware.ts +++ b/backend/src/database/tenant.middleware.ts @@ -13,8 +13,8 @@ export interface TenantRequest extends Request { @Injectable() export class TenantMiddleware implements NestMiddleware { - // In-memory cache for org status to avoid DB hit per request - private orgStatusCache = new Map(); + // In-memory cache for org info to avoid DB hit per request + private orgCache = new Map(); private static readonly CACHE_TTL = 60_000; // 60 seconds constructor( @@ -30,23 +30,25 @@ export class TenantMiddleware implements NestMiddleware { const token = authHeader.substring(7); const secret = this.configService.get('JWT_SECRET'); const decoded = jwt.verify(token, secret!) as any; - if (decoded?.orgSchema) { - // Check if the org is still active (catches post-JWT suspension) - if (decoded.orgId) { - const status = await this.getOrgStatus(decoded.orgId); - if (status && ['suspended', 'archived'].includes(status)) { + if (decoded?.orgId) { + // Look up org info (status + schema) from orgId with caching + const orgInfo = await this.getOrgInfo(decoded.orgId); + if (orgInfo) { + if (['suspended', 'archived'].includes(orgInfo.status)) { res.status(403).json({ statusCode: 403, - message: `This organization has been ${status}. Please contact your administrator.`, + message: `This organization has been ${orgInfo.status}. Please contact your administrator.`, }); return; } + req.tenantSchema = orgInfo.schemaName; } - - req.tenantSchema = decoded.orgSchema; req.orgId = decoded.orgId; req.userId = decoded.sub; req.userRole = decoded.role; + } else if (decoded?.sub) { + // Superadmin or user without org — still set userId + req.userId = decoded.sub; } } catch { // Token invalid or expired - let Passport handle the auth error @@ -55,19 +57,23 @@ export class TenantMiddleware implements NestMiddleware { next(); } - private async getOrgStatus(orgId: string): Promise { - const cached = this.orgStatusCache.get(orgId); + private async getOrgInfo(orgId: string): Promise<{ status: string; schemaName: string } | null> { + const cached = this.orgCache.get(orgId); if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) { - return cached.status; + return { status: cached.status, schemaName: cached.schemaName }; } try { const result = await this.dataSource.query( - `SELECT status FROM shared.organizations WHERE id = $1`, + `SELECT status, schema_name as "schemaName" FROM shared.organizations WHERE id = $1`, [orgId], ); if (result.length > 0) { - this.orgStatusCache.set(orgId, { status: result[0].status, cachedAt: Date.now() }); - return result[0].status; + this.orgCache.set(orgId, { + status: result[0].status, + schemaName: result[0].schemaName, + cachedAt: Date.now(), + }); + return { status: result[0].status, schemaName: result[0].schemaName }; } } catch { // Non-critical — don't block requests on cache miss errors diff --git a/backend/src/main.ts b/backend/src/main.ts index 1c29cec..43f28ff 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -3,6 +3,7 @@ import * as os from 'node:os'; import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import helmet from 'helmet'; import { AppModule } from './app.module'; const cluster = _cluster as any; // Cast to 'any' bypasses the missing property errors @@ -41,6 +42,24 @@ async function bootstrap() { app.setGlobalPrefix('api'); + // Security headers — Helmet sets CSP, X-Frame-Options, X-Content-Type-Options, + // Referrer-Policy, Permissions-Policy, and removes X-Powered-By + app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'", 'https://chat.hoaledgeriq.com'], + connectSrc: ["'self'", 'https://chat.hoaledgeriq.com', 'wss://chat.hoaledgeriq.com'], + imgSrc: ["'self'", 'data:', 'https://chat.hoaledgeriq.com'], + styleSrc: ["'self'", "'unsafe-inline'"], + frameSrc: ["'self'", 'https://chat.hoaledgeriq.com'], + fontSrc: ["'self'", 'data:'], + }, + }, + }), + ); + // Request logging — only in development (too noisy / slow for prod) if (!isProduction) { app.use((req: any, _res: any, next: any) => { @@ -63,15 +82,17 @@ async function bootstrap() { credentials: true, }); - // Swagger docs — available in all environments - const config = new DocumentBuilder() - .setTitle('HOA LedgerIQ API') - .setDescription('API for the HOA LedgerIQ') - .setVersion('2026.03.10') - .addBearerAuth() - .build(); - const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api/docs', app, document); + // Swagger docs — disabled in production to avoid exposing API surface + if (!isProduction) { + const config = new DocumentBuilder() + .setTitle('HOA LedgerIQ API') + .setDescription('API for the HOA LedgerIQ') + .setVersion('2026.3.11') + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api/docs', app, document); + } await app.listen(3000); console.log(`Backend worker ${process.pid} listening on port 3000`); diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 66c3356..3cffad7 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -9,6 +9,7 @@ import { } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; +import { Throttle } from '@nestjs/throttler'; import { AuthService } from './auth.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; @@ -23,12 +24,14 @@ export class AuthController { @Post('register') @ApiOperation({ summary: 'Register a new user' }) + @Throttle({ default: { limit: 5, ttl: 60000 } }) async register(@Body() dto: RegisterDto) { return this.authService.register(dto); } @Post('login') @ApiOperation({ summary: 'Login with email and password' }) + @Throttle({ default: { limit: 5, ttl: 60000 } }) @UseGuards(AuthGuard('local')) async login(@Request() req: any, @Body() _dto: LoginDto) { const ip = req.headers['x-forwarded-for'] || req.ip; diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index d50eeb0..d57b403 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -118,7 +118,6 @@ export class AuthService { sub: user.id, email: user.email, orgId: membership.organizationId, - orgSchema: membership.organization.schemaName, role: membership.role, }; @@ -177,7 +176,6 @@ export class AuthService { if (defaultOrg) { payload.orgId = defaultOrg.organizationId; - payload.orgSchema = defaultOrg.organization?.schemaName; payload.role = defaultOrg.role; } @@ -195,7 +193,6 @@ export class AuthService { organizations: orgs.map((uo) => ({ id: uo.organizationId, name: uo.organization?.name, - schemaName: uo.organization?.schemaName, status: uo.organization?.status, role: uo.role, })), diff --git a/backend/src/modules/auth/strategies/jwt.strategy.ts b/backend/src/modules/auth/strategies/jwt.strategy.ts index cf0d863..6580d7b 100644 --- a/backend/src/modules/auth/strategies/jwt.strategy.ts +++ b/backend/src/modules/auth/strategies/jwt.strategy.ts @@ -18,7 +18,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) { sub: payload.sub, email: payload.email, orgId: payload.orgId, - orgSchema: payload.orgSchema, role: payload.role, isSuperadmin: payload.isSuperadmin || false, impersonatedBy: payload.impersonatedBy || null, diff --git a/backend/src/modules/health-scores/health-scores.controller.ts b/backend/src/modules/health-scores/health-scores.controller.ts index 4e2bddb..2e163c5 100644 --- a/backend/src/modules/health-scores/health-scores.controller.ts +++ b/backend/src/modules/health-scores/health-scores.controller.ts @@ -16,7 +16,7 @@ export class HealthScoresController { @Get('latest') @ApiOperation({ summary: 'Get latest operating and reserve health scores' }) getLatest(@Req() req: any) { - const schema = req.user?.orgSchema; + const schema = req.tenantSchema; return this.service.getLatestScores(schema); } @@ -24,7 +24,7 @@ export class HealthScoresController { @ApiOperation({ summary: 'Trigger both health score recalculations (async — returns immediately)' }) @AllowViewer() async calculate(@Req() req: any) { - const schema = req.user?.orgSchema; + const schema = req.tenantSchema; // Fire-and-forget — background processing saves results to DB Promise.all([ @@ -44,7 +44,7 @@ export class HealthScoresController { @ApiOperation({ summary: 'Trigger operating fund health score recalculation (async)' }) @AllowViewer() async calculateOperating(@Req() req: any) { - const schema = req.user?.orgSchema; + const schema = req.tenantSchema; // Fire-and-forget this.service.calculateScore(schema, 'operating').catch((err) => { @@ -61,7 +61,7 @@ export class HealthScoresController { @ApiOperation({ summary: 'Trigger reserve fund health score recalculation (async)' }) @AllowViewer() async calculateReserve(@Req() req: any) { - const schema = req.user?.orgSchema; + const schema = req.tenantSchema; // Fire-and-forget this.service.calculateScore(schema, 'reserve').catch((err) => { diff --git a/frontend/index.html b/frontend/index.html index b344cc0..a3be033 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -23,6 +23,20 @@ }) } })(document,"script"); + window.addEventListener('chatwoot:ready', function() { + try { + var raw = localStorage.getItem('ledgeriq-auth'); + if (!raw) return; + var auth = JSON.parse(raw); + var user = auth && auth.state && auth.state.user; + if (user && window.$chatwoot) { + window.$chatwoot.setUser(user.id, { + name: (user.firstName || '') + ' ' + (user.lastName || ''), + email: user.email + }); + } + } catch (e) {} + }); diff --git a/frontend/package.json b/frontend/package.json index 630271e..0e1717e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hoa-ledgeriq-frontend", - "version": "2026.03.10", + "version": "2026.3.11", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/pages/auth/SelectOrgPage.tsx b/frontend/src/pages/auth/SelectOrgPage.tsx index 32f4dce..fe1fa65 100644 --- a/frontend/src/pages/auth/SelectOrgPage.tsx +++ b/frontend/src/pages/auth/SelectOrgPage.tsx @@ -120,11 +120,6 @@ export function SelectOrgPage() { {org.name} {org.role} - {org.schemaName && ( - - {org.schemaName} - - )} diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx index 2583714..8491396 100644 --- a/frontend/src/pages/settings/SettingsPage.tsx +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -38,10 +38,6 @@ export function SettingsPage() { Your Role {currentOrg?.role || 'N/A'} - - Schema - {currentOrg?.schemaName || 'N/A'} - diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index 9475e7c..4dc2dbe 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -5,7 +5,6 @@ interface Organization { id: string; name: string; role: string; - schemaName?: string; status?: string; settings?: Record; } From 04771f370c0981c07e5fe0e6dc8e7f5094add5fc Mon Sep 17 00:00:00 2001 From: olsch01 Date: Wed, 11 Mar 2026 15:42:15 -0400 Subject: [PATCH 2/2] fix: clarify reserve health score when no components are entered - Add missing-data warning when reserve_components table is empty so users see "No reserve components found" on the dashboard - Change AI prompt to show "N/A" instead of "0.0%" for funded ratio when no components exist, preventing misleading "0% funded" reports - Instruct AI not to report 0% funded when data is simply missing Co-Authored-By: Claude Opus 4.6 --- .../modules/health-scores/health-scores.service.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/src/modules/health-scores/health-scores.service.ts b/backend/src/modules/health-scores/health-scores.service.ts index 762d516..ffd73a2 100644 --- a/backend/src/modules/health-scores/health-scores.service.ts +++ b/backend/src/modules/health-scores/health-scores.service.ts @@ -220,6 +220,14 @@ export class HealthScoresService { missing.push(`No budget found for ${year}. Upload or create an annual budget.`); } + // Should have reserve components (warn but don't block) + const components = await qr.query( + `SELECT COUNT(*) as cnt FROM reserve_components`, + ); + if (parseInt(components[0].cnt) === 0) { + missing.push('No reserve components found. Add reserve components (roof, parking, pool, etc.) with replacement costs for an accurate funded-ratio calculation.'); + } + // Should have capital projects (warn but don't block) const projects = await qr.query( `SELECT COUNT(*) as cnt FROM projects WHERE is_active = true`, @@ -997,8 +1005,8 @@ Reserve Cash (bank accounts): $${data.reserveCash.toFixed(2)} Reserve Investments: $${data.totalInvestments.toFixed(2)} Total Reserve Fund: $${data.totalReserveFund.toFixed(2)} -Total Replacement Cost (all components): $${data.totalReplacementCost.toFixed(2)} -Percent Funded: ${data.percentFunded.toFixed(1)}% +Total Replacement Cost (all components): ${data.totalReplacementCost > 0 ? '$' + data.totalReplacementCost.toFixed(2) : '$0.00 (no reserve components entered — funded ratio cannot be calculated)'} +Percent Funded: ${data.totalReplacementCost > 0 ? data.percentFunded.toFixed(1) + '%' : 'N/A — no reserve components with replacement costs have been entered. Do NOT report a 0% funded ratio; instead note that funded ratio is unavailable due to missing component data.'} Annual Reserve Contribution (budgeted income): $${data.annualReserveContribution.toFixed(2)} Annual Reserve Expenses (budgeted): $${data.annualReserveExpenses.toFixed(2)}