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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ jobs:
- name: Install dependencies
run: npm ci

# Fail if tsconfig.json / tsconfig.app.json weaken strict mode
- name: Check TypeScript strict flags
run: npm run check:tsconfig-strict

# Issue #924 – Fail CI on any ESLint warning or error
- name: Run ESLint (zero warnings allowed)
run: npm run lint -- --max-warnings=0
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,8 @@ For support, email support@propchain.com or join our Slack channel
## Developer Requirements — TypeScript & Linting

- **TypeScript strict mode:** The project now enables `strict` TypeScript checks. The base config is in [tsconfig.json](tsconfig.json#L1).
- **Key compiler flags enforced:** `noImplicitAny`, `strictNullChecks` and related strict checks are enabled for app builds via [tsconfig.app.json](tsconfig.app.json#L1).
- **Key compiler flags enforced:** `strict`, `noImplicitAny`, `strictNullChecks`, `useUnknownInCatchVariables` and `noImplicitOverride` are enabled for app builds via [tsconfig.app.json](tsconfig.app.json#L1), which must not override them to `false`. Only `strictPropertyInitialization` is relaxed (NestJS DI-injected properties).
- **Guard:** `npm run check:tsconfig-strict` ([scripts/check-tsconfig-strict.js](scripts/check-tsconfig-strict.js)) fails if either config weakens these flags; CI runs it in the lint job.
- **ESLint rules:** `@typescript-eslint/no-explicit-any` is set to `error` and explicit boundary/return types are encouraged via `@typescript-eslint/explicit-module-boundary-types` and `@typescript-eslint/explicit-function-return-type` (set to `warn`). See [.eslintrc.js](.eslintrc.js#L1).

Local checks before committing/pushing:
Expand All @@ -486,6 +487,9 @@ npm ci
# Run linter (auto-fixable issues)
npm run lint

# Verify tsconfig strict flags are intact
npm run check:tsconfig-strict

# Build to verify TypeScript strict checks
npm run build
```
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"scripts": {
"prebuild": "rimraf dist tsconfig.tsbuildinfo",
"build": "nest build",
"check:tsconfig-strict": "node scripts/check-tsconfig-strict.js",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"prestart": "rimraf dist tsconfig.tsbuildinfo",
"start": "nest start",
Expand Down
77 changes: 77 additions & 0 deletions scripts/check-tsconfig-strict.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#!/usr/bin/env node
/**
* CI guard: fail if the app build config weakens TypeScript strictness.
*
* README "Developer Requirements" promises strict mode, noImplicitAny and
* strictNullChecks for app builds. This script checks that tsconfig.json and
* tsconfig.app.json (which extends it) keep those flags on after resolution.
*
* Usage: node scripts/check-tsconfig-strict.js
*/

const fs = require('fs');
const path = require('path');

const ROOT = path.resolve(__dirname, '..');
const CONFIGS = ['tsconfig.json', 'tsconfig.app.json'];

// Flags that must resolve to true. Flags implied by `strict` are listed so an
// explicit `false` override is also caught.
const REQUIRED_TRUE = [
'strict',
'noImplicitAny',
'strictNullChecks',
'strictFunctionTypes',
'strictBindCallApply',
'noImplicitThis',
'alwaysStrict',
'useUnknownInCatchVariables',
'noImplicitOverride',
];

// Implied by `strict`; an explicit false is still a violation.
const IMPLIED_BY_STRICT = new Set([
'noImplicitAny',
'strictNullChecks',
'strictFunctionTypes',
'strictBindCallApply',
'noImplicitThis',
'alwaysStrict',
'useUnknownInCatchVariables',
]);

function readJsonc(file) {
const text = fs.readFileSync(file, 'utf8');
// Strip // and /* */ comments and trailing commas (tsconfig allows them).
const stripped = text
.replace(/("(?:[^"\\]|\\.)*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (m, str) => str ?? '')
.replace(/,(\s*[}\]])/g, '$1');
return JSON.parse(stripped);
}

function resolveCompilerOptions(file) {
const config = readJsonc(file);
let base = {};
if (config.extends) {
base = resolveCompilerOptions(path.resolve(path.dirname(file), config.extends));
}
return { ...base, ...(config.compilerOptions ?? {}) };
}

const failures = [];
for (const name of CONFIGS) {
const options = resolveCompilerOptions(path.join(ROOT, name));
for (const flag of REQUIRED_TRUE) {
const value = options[flag];
const ok = value === true || (value === undefined && IMPLIED_BY_STRICT.has(flag) && options.strict === true);
if (!ok) failures.push(`${name}: "${flag}" resolves to ${JSON.stringify(value)} (expected true)`);
}
}

if (failures.length) {
console.error('TypeScript strictness check failed:\n ' + failures.join('\n '));
console.error('\nSee README "Developer Requirements — TypeScript & Linting".');
process.exit(1);
}

console.log(`TypeScript strictness check passed for ${CONFIGS.join(', ')}.`);
34 changes: 34 additions & 0 deletions src/config/security-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Security headers middleware.
*
* Every route gets the strict DEFAULT_CONTENT_SECURITY_POLICY. The Swagger UI
* route gets a slightly wider policy (still script-src 'self') so its locally
* served swagger-ui-dist assets render.
*/

import type { NextFunction, Request, Response } from 'express';
import {
DEFAULT_CONTENT_SECURITY_POLICY,
SWAGGER_SERVERS,
buildDocsContentSecurityPolicy,
isSwaggerDocsPath,
} from './swagger.config';

export function createSecurityHeadersMiddleware(
docsServerUrls: string[] = SWAGGER_SERVERS.map((s) => s.url),
): (req: Request, res: Response, next: NextFunction) => void {
const docsCsp = buildDocsContentSecurityPolicy(docsServerUrls);

return (req, res, next) => {
res.setHeader(
'Content-Security-Policy',
isSwaggerDocsPath(req.path) ? docsCsp : DEFAULT_CONTENT_SECURITY_POLICY,
);
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
next();
};
}
65 changes: 56 additions & 9 deletions src/config/swagger.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,52 @@ import { INestApplication, Logger } from '@nestjs/common';

const logger = new Logger('SwaggerConfig');

/** Route prefix of the Swagger UI (no leading slash). */
export const SWAGGER_DOCS_PATH = 'api/docs';

/** Servers advertised in the spec (and allowed as connect-src on the docs page). */
export const SWAGGER_SERVERS = [
{ url: 'http://localhost:3000', description: 'Development Server' },
{ url: 'https://api.propchain.io', description: 'Production Server' },
];

/** Content-Security-Policy applied to every non-docs route (see main.ts). */
export const DEFAULT_CONTENT_SECURITY_POLICY =
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'";

/** True when the request path is the Swagger UI or one of its assets. */
export function isSwaggerDocsPath(path: string): boolean {
const docsRoot = `/${SWAGGER_DOCS_PATH}`;
return path === docsRoot || path.startsWith(`${docsRoot}/`) || path.startsWith(`${docsRoot}-`);
}

/**
* CSP for the Swagger UI route. Scripts are still restricted to 'self' (all
* assets are served locally); only what Swagger UI v5 needs on top of the
* default policy is added: data: images used by swagger-ui.css, and
* connect-src for the servers listed in the spec so "Try it out" works.
*/
export function buildDocsContentSecurityPolicy(serverUrls: string[] = []): string {
const origins = new Set<string>();
for (const url of serverUrls) {
try {
origins.add(new URL(url).origin);
} catch {
// relative server URLs are covered by 'self'
}
}
return [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
`connect-src ${["'self'", ...origins].join(' ')}`,
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
].join('; ');
}

interface AppWithOpenApiDoc {
openAPIDocument?: OpenAPIObject;
}
Expand Down Expand Up @@ -54,8 +100,6 @@ export function setupSwagger(app: INestApplication): OpenAPIObject {
},
'api-version',
)
.addServer('http://localhost:3000', 'Development Server')
.addServer('https://api.propchain.io', 'Production Server')
.addTag('Authentication', 'User authentication and authorization')
.addTag('Users', 'User management endpoints')
.addTag('Properties', 'Property management endpoints')
Expand All @@ -78,15 +122,18 @@ export function setupSwagger(app: INestApplication): OpenAPIObject {
.addTag('Analytics', 'Analytics and reporting endpoints')
.build();

config.servers = SWAGGER_SERVERS.map(({ url, description }) => ({ url, description }));

const document = SwaggerModule.createDocument(app, config);

// Setup Swagger UI at /api/docs
SwaggerModule.setup('api/docs', app, document, {
SwaggerModule.setup(SWAGGER_DOCS_PATH, app, document, {
swaggerOptions: {
persistAuthorizationData: true,
persistAuthorization: true,
displayRequestDuration: true,
filter: true,
showRequestHeaders: true,
// Disable the badge that calls out to validator.swagger.io
validatorUrl: null,
supportedSubmitMethods: ['get', 'post', 'put', 'patch', 'delete'],
docExpansion: 'list',
defaultModelsExpandDepth: 1,
Expand Down Expand Up @@ -127,10 +174,10 @@ export function setupSwagger(app: INestApplication): OpenAPIObject {
color: #00d4ff;
}
`,
customJs: [
'https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui.js',
'https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui-standalone-preset.js',
],
// No customJs: Swagger UI v5 assets (bundle, preset, init script, CSS) are
// served from the local swagger-ui-dist package under /api/docs, so the
// page works with the strict CSP from buildDocsContentSecurityPolicy and
// makes no external requests.
});

logger.log('Swagger UI available at http://localhost:3000/api/docs');
Expand Down
18 changes: 4 additions & 14 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import { RateLimitGuard } from './auth/guards/rate-limit.guard';
import { RateLimitService } from './auth/rate-limit.service';
import { RateLimitHeadersInterceptor } from './auth/interceptors/rate-limit-headers.interceptor';
import { ResponseFormatInterceptor } from './common/interceptors/response-format.interceptor';
import { setupOpenAPIEndpoint, setupSwagger } from './config/swagger.config';
import { setupSwagger } from './config/swagger.config';
import { createSecurityHeadersMiddleware } from './config/security-headers';
import { validateEnvironment } from './utils/validate-env';
// Issue #914 – Structured JSON logging in production, pretty-print in dev
import { AppLogger } from './common/logger';
Expand Down Expand Up @@ -73,19 +74,8 @@ async function bootstrap() {
allowedHeaders: ['Content-Type', 'Authorization', 'API-Version', 'api-key', 'x-api-key'],
});

// Security headers middleware
app.use((req: any, res: any, next: any) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'",
);
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
next();
});
// Security headers middleware (CSP is relaxed only for the Swagger UI route)
app.use(createSecurityHeadersMiddleware());

// Issue #964 / #1234 – Localize validation messages using the request's
// Accept-Language (and optional user preference) captured by middleware
Expand Down
Loading