Enhance Salesforce order fulfillment process and security measures

- Updated PLESK_DEPLOYMENT.md to include new Salesforce credentials and webhook security configurations.
- Refactored order fulfillment controller to streamline the process and improve readability.
- Introduced EnhancedWebhookSignatureGuard for improved HMAC signature validation and nonce management.
- Updated various documentation files to reflect changes in endpoint naming from `/provision` to `/fulfill` for clarity and consistency.
- Enhanced Redis integration for nonce storage to prevent replay attacks.
- Removed deprecated WebhookSignatureGuard in favor of the new enhanced guard.
This commit is contained in:
T. Narantuya 2025-09-04 14:17:54 +09:00
parent 33e3963fcf
commit ece6821766
22 changed files with 296 additions and 201 deletions

View File

@ -90,6 +90,12 @@ customer-portal/
- `DATABASE_URL` should use `database:5432`
- `REDIS_URL` should use `cache:6379`
- Set `JWT_SECRET` to a strong value
- Salesforce credentials: `SF_LOGIN_URL`, `SF_CLIENT_ID`, `SF_USERNAME`
- Salesforce private key: set `SF_PRIVATE_KEY_PATH=/app/secrets/sf-private.key` and mount `/app/secrets`
- Webhook secrets: `SF_WEBHOOK_SECRET` (Salesforce), `WHMCS_WEBHOOK_SECRET` (if using WHMCS webhooks)
- Webhook tolerances: `WEBHOOK_TIMESTAMP_TOLERANCE=300000` (ms; optional)
- Optional IP allowlists: `SF_WEBHOOK_IP_ALLOWLIST`, `WHMCS_WEBHOOK_IP_ALLOWLIST` (CSV of IP/CIDR)
- Pricebook: `PORTAL_PRICEBOOK_ID`
### Image Build and Upload
@ -123,6 +129,17 @@ In Plesk → Docker → Images, upload both tar files. Then use `compose-plesk.y
- `/``portal-frontend` port `3000`
- `/api``portal-backend` port `4000`
### Webhook Security (Plesk)
- Endpoint for Salesforce Quick Action:
- `POST /api/orders/{sfOrderId}/fulfill`
- Required backend env (see above). Ensure the same HMAC secret is configured in Salesforce.
- The backend guard enforces:
- HMAC for all webhooks
- Salesforce: timestamp + nonce with Redis-backed replay protection
- WHMCS: timestamp/nonce optional (validated if present)
- Health check `/health` includes `integrations.redis` to verify nonce storage.
Alternatively, load via SSH on the Plesk host:
```bash

View File

@ -247,6 +247,7 @@ When running `pnpm dev:tools`, you get access to:
### Webhooks
- `POST /api/orders/:sfOrderId/fulfill` - Secure Salesforce-initiated order fulfillment
- `POST /api/webhooks/whmcs` - WHMCS action hooks → update mirrors + bust cache
## Frontend Pages
@ -411,3 +412,4 @@ rm -rf node_modules && pnpm install
## License
[Your License Here]
See `docs/RUNBOOK_PROVISIONING.md` for the provisioning runbook.

View File

@ -1,4 +1,4 @@
import { Controller, Get } from "@nestjs/common";
import { Controller, Get, Inject } from "@nestjs/common";
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { PrismaService } from "../common/prisma/prisma.service";
import { getErrorMessage } from "../common/utils/error.util";
@ -6,6 +6,7 @@ import { InjectQueue } from "@nestjs/bullmq";
import { Queue } from "bullmq";
import { ConfigService } from "@nestjs/config";
import { Public } from "../auth/decorators/public.decorator";
import { CacheService } from "../common/cache/cache.service";
@ApiTags("Health")
@Controller("health")
@ -14,7 +15,8 @@ export class HealthController {
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
@InjectQueue("email") private readonly emailQueue: Queue
@InjectQueue("email") private readonly emailQueue: Queue,
private readonly cache: CacheService
) {}
@Get()
@ -45,6 +47,15 @@ export class HealthController {
"delayed"
);
// Check Redis availability by a simple set/get on a volatile key
const nonceProbeKey = "health:nonce:probe";
let redisStatus: "connected" | "degraded" | "unavailable" = "connected";
try {
await this.cache.set(nonceProbeKey, 1, 5);
} catch {
redisStatus = "unavailable";
}
return {
status: "ok",
timestamp: new Date().toISOString(),
@ -55,6 +66,9 @@ export class HealthController {
queues: {
email: emailQueueInfo,
},
integrations: {
redis: redisStatus,
},
features: {
emailEnabled: this.config.get("EMAIL_ENABLED", "true") === "true",
emailQueued: this.config.get("EMAIL_USE_QUEUE", "true") === "true",

View File

@ -145,4 +145,6 @@ export class OrderFulfillmentController {
throw error;
}
}
// Removed /provision alias to avoid confusion — use /fulfill only
}

View File

@ -4,6 +4,7 @@ import { OrderFulfillmentController } from "./controllers/order-fulfillment.cont
import { VendorsModule } from "../vendors/vendors.module";
import { MappingsModule } from "../mappings/mappings.module";
import { UsersModule } from "../users/users.module";
import { WebhooksModule } from "../webhooks/webhooks.module";
// Clean modular order services
import { OrderValidator } from "./services/order-validator.service";
@ -19,7 +20,7 @@ import { OrderFulfillmentOrchestrator } from "./services/order-fulfillment-orche
import { OrderFulfillmentErrorService } from "./services/order-fulfillment-error.service";
@Module({
imports: [VendorsModule, MappingsModule, UsersModule],
imports: [VendorsModule, MappingsModule, UsersModule, WebhooksModule],
controllers: [OrdersController, OrderFulfillmentController],
providers: [
// Order creation services (modular)

View File

@ -5,6 +5,7 @@ import { WhmcsOrderService, WhmcsOrderResult } from "../../vendors/whmcs/service
import { OrderOrchestrator } from "./order-orchestrator.service";
import { OrderFulfillmentValidator, OrderFulfillmentValidationResult } from "./order-fulfillment-validator.service";
import { OrderWhmcsMapper, OrderItemMappingResult } from "./order-whmcs-mapper.service";
import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service";
import { getErrorMessage } from "../../common/utils/error.util";
@ -40,7 +41,8 @@ export class OrderFulfillmentOrchestrator {
private readonly whmcsOrderService: WhmcsOrderService,
private readonly orderOrchestrator: OrderOrchestrator,
private readonly orderFulfillmentValidator: OrderFulfillmentValidator,
private readonly orderWhmcsMapper: OrderWhmcsMapper
private readonly orderWhmcsMapper: OrderWhmcsMapper,
private readonly orderFulfillmentErrorService: OrderFulfillmentErrorService
) {}
/**
@ -276,7 +278,7 @@ export class OrderFulfillmentOrchestrator {
context: OrderFulfillmentContext,
error: Error
): Promise<void> {
const errorCode = this.determineErrorCode(error);
const errorCode = this.orderFulfillmentErrorService.determineErrorCode(error);
const userMessage = error.message;
this.logger.error("Fulfillment orchestration failed", {
@ -309,25 +311,6 @@ export class OrderFulfillmentOrchestrator {
}
}
/**
* Determine error code based on error type
*/
private determineErrorCode(error: Error): string {
if (error.message.includes("Payment method missing")) {
return "PAYMENT_METHOD_MISSING";
}
if (error.message.includes("not found")) {
return "ORDER_NOT_FOUND";
}
if (error.message.includes("WHMCS")) {
return "WHMCS_ERROR";
}
if (error.message.includes("mapping")) {
return "MAPPING_ERROR";
}
return "FULFILLMENT_ERROR";
}
/**
* Get fulfillment summary from context
*/

View File

@ -5,6 +5,7 @@ import { WhmcsOrderService } from "../../vendors/whmcs/services/whmcs-order.serv
import { MappingsService } from "../../mappings/mappings.service";
import { getErrorMessage } from "../../common/utils/error.util";
import { SalesforceOrder } from "../types/salesforce-order.types";
import { ConfigService } from "@nestjs/config";
export interface OrderFulfillmentValidationResult {
sfOrder: SalesforceOrder;
@ -23,7 +24,8 @@ export class OrderFulfillmentValidator {
@Inject(Logger) private readonly logger: Logger,
private readonly salesforceService: SalesforceService,
private readonly whmcsOrderService: WhmcsOrderService,
private readonly mappingsService: MappingsService
private readonly mappingsService: MappingsService,
private readonly configService: ConfigService
) {}
/**
@ -198,7 +200,8 @@ export class OrderFulfillmentValidator {
try {
const requestTime = new Date(timestamp).getTime();
const now = Date.now();
const maxAge = 5 * 60 * 1000; // 5 minutes
const maxAge =
this.configService.get<number>("WEBHOOK_TIMESTAMP_TOLERANCE") ?? 5 * 60 * 1000; // default 5m
if (Math.abs(now - requestTime) > maxAge) {
throw new BadRequestException("Request timestamp is too old");

View File

@ -2,6 +2,7 @@ import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { getErrorMessage } from "../../../common/utils/error.util";
import { SalesforceConnection } from "./salesforce-connection.service";
import { SalesforceQueryResult as SfQueryResult } from "../../../orders/types/salesforce-order.types";
export interface AccountData {
name: string;
@ -20,22 +21,12 @@ export interface UpsertResult {
created: boolean;
}
interface SalesforceQueryResult {
records: SalesforceAccount[];
totalSize: number;
}
interface SalesforceAccount {
Id: string;
Name: string;
WH_Account__c?: string;
}
interface _SalesforceCreateResult {
id: string;
success: boolean;
}
@Injectable()
export class SalesforceAccountService {
constructor(
@ -49,7 +40,7 @@ export class SalesforceAccountService {
try {
const result = (await this.connection.query(
`SELECT Id FROM Account WHERE SF_Account_No__c = '${this.safeSoql(customerNumber.trim())}'`
)) as SalesforceQueryResult;
)) as SfQueryResult<SalesforceAccount>;
return result.totalSize > 0 ? { id: result.records[0].Id } : null;
} catch (error) {
this.logger.error("Failed to find account by customer number", {
@ -67,7 +58,7 @@ export class SalesforceAccountService {
try {
const result = (await this.connection.query(
`SELECT Id, Name, WH_Account__c FROM Account WHERE Id = '${this.safeSoql(accountId.trim())}'`
)) as SalesforceQueryResult;
)) as SfQueryResult<SalesforceAccount>;
if (result.totalSize === 0) {
return null;
@ -120,7 +111,7 @@ export class SalesforceAccountService {
try {
const existingAccount = (await this.connection.query(
`SELECT Id FROM Account WHERE Name = '${this.safeSoql(accountData.name.trim())}'`
)) as SalesforceQueryResult;
)) as SfQueryResult<SalesforceAccount>;
const sfData = {
Name: accountData.name.trim(),
@ -168,7 +159,7 @@ export class SalesforceAccountService {
SELECT Id, Name
FROM Account
WHERE Id = '${this.validateId(accountId)}'
`)) as SalesforceQueryResult;
`)) as SfQueryResult<SalesforceAccount>;
return result.totalSize > 0 ? result.records[0] : null;
} catch (error) {

View File

@ -4,6 +4,10 @@ import { getErrorMessage } from "../../../common/utils/error.util";
import { SalesforceConnection } from "./salesforce-connection.service";
import { SupportCase, CreateCaseRequest, CaseType } from "@customer-portal/shared";
import { CaseStatus, CasePriority, CASE_STATUS, CASE_PRIORITY } from "@customer-portal/shared";
import {
SalesforceQueryResult as SfQueryResult,
SalesforceCreateResult as SfCreateResult,
} from "../../../orders/types/salesforce-order.types";
export interface CaseQueryParams {
status?: string;
@ -27,11 +31,6 @@ interface CaseData {
origin?: string;
}
interface SalesforceQueryResult {
records: SalesforceCase[];
totalSize: number;
}
interface SalesforceCase {
Id: string;
CaseNumber: string;
@ -52,11 +51,6 @@ interface SalesforceCase {
};
}
interface SalesforceCreateResult {
id: string;
success: boolean;
}
@Injectable()
export class SalesforceCaseService {
constructor(
@ -92,7 +86,7 @@ export class SalesforceCaseService {
query += ` OFFSET ${params.offset}`;
}
const result = (await this.connection.query(query)) as SalesforceQueryResult;
const result = (await this.connection.query(query)) as SfQueryResult<SalesforceCase>;
const cases = result.records.map(record => this.transformCase(record));
@ -157,7 +151,7 @@ export class SalesforceCaseService {
WHERE Email = '${this.safeSoql(userData.email)}'
AND AccountId = '${userData.accountId}'
LIMIT 1
`)) as SalesforceQueryResult;
`)) as SfQueryResult<SalesforceCase>;
if (existingContact.totalSize > 0) {
return existingContact.records[0].Id;
@ -172,7 +166,7 @@ export class SalesforceCaseService {
};
const sobject = this.connection.sobject("Contact") as unknown as {
create: (data: Record<string, unknown>) => Promise<SalesforceCreateResult>;
create: (data: Record<string, unknown>) => Promise<SfCreateResult>;
};
const result = await sobject.create(contactData);
return result.id;
@ -201,7 +195,7 @@ export class SalesforceCaseService {
};
const sobject = this.connection.sobject("Case") as unknown as {
create: (data: Record<string, unknown>) => Promise<SalesforceCreateResult>;
create: (data: Record<string, unknown>) => Promise<SfCreateResult>;
};
const result = await sobject.create(sfData);
@ -211,7 +205,7 @@ export class SalesforceCaseService {
CreatedDate, LastModifiedDate, ClosedDate, ContactId, AccountId, OwnerId, Owner.Name
FROM Case
WHERE Id = '${result.id}'
`)) as SalesforceQueryResult;
`)) as SfQueryResult<SalesforceCase>;
return createdCase.records[0];
}

View File

@ -12,11 +12,6 @@ export interface SalesforceSObjectApi {
update?: (data: Record<string, unknown> & { Id: string }) => Promise<unknown>;
}
interface _SalesforceRetryableSObjectApi extends SalesforceSObjectApi {
create: (data: Record<string, unknown>) => Promise<{ id?: string }>;
update?: (data: Record<string, unknown> & { Id: string }) => Promise<unknown>;
}
@Injectable()
export class SalesforceConnection {
private connection: jsforce.Connection;
@ -48,18 +43,26 @@ export class SalesforceConnection {
throw new Error(isProd ? "Salesforce configuration is missing" : devMessage);
}
// Resolve private key strictly relative to repo root and enforce secrets directory
// Use monorepo layout assumption: apps/bff -> repo root is two levels up
const appDir = process.cwd();
const repoRoot = path.resolve(appDir, "../../");
const secretsDir = path.resolve(repoRoot, "secrets");
const resolvedKeyPath = path.resolve(repoRoot, privateKeyPath);
// Resolve private key and enforce allowed secrets directories
// Supports both local dev (./secrets) and container prod (/app/secrets)
const isAbsolute = path.isAbsolute(privateKeyPath);
const resolvedKeyPath = isAbsolute
? privateKeyPath
: path.resolve(process.cwd(), privateKeyPath);
const allowedBases = [
path.resolve(process.cwd(), "secrets"),
"/app/secrets",
].map((p) => path.normalize(p) + path.sep);
// Enforce the key to be under repo-root/secrets
const normalizedKeyPath = path.normalize(resolvedKeyPath);
const normalizedSecretsDir = path.normalize(secretsDir) + path.sep;
if (!(normalizedKeyPath + path.sep).startsWith(normalizedSecretsDir)) {
const devMsg = `Salesforce private key must be located under the root secrets directory: ${secretsDir}`;
const isUnderAllowedBase = allowedBases.some((base) =>
(normalizedKeyPath + path.sep).startsWith(base)
);
if (!isUnderAllowedBase) {
const devMsg = `Salesforce private key must be under one of the allowed secrets directories: ${allowedBases
.map((b) => b.replace(/\\$/, ""))
.join(", ")}. Got: ${normalizedKeyPath}`;
throw new Error(isProd ? "Invalid Salesforce private key path" : devMsg);
}

View File

@ -1,9 +1,9 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common";
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, Inject } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Request } from "express";
import crypto from "node:crypto";
import { Logger } from "nestjs-pino";
import { Inject } from "@nestjs/common";
import { CacheService } from "../../common/cache/cache.service";
interface WebhookRequest extends Request {
webhookMetadata?: {
@ -16,36 +16,48 @@ interface WebhookRequest extends Request {
@Injectable()
export class EnhancedWebhookSignatureGuard implements CanActivate {
private readonly nonceStore = new Set<string>(); // In production, use Redis
private readonly maxNonceAge = 5 * 60 * 1000; // 5 minutes
private readonly allowedIps: string[];
// Fallback in-memory nonce store for local/dev only
private readonly nonceStore = new Set<string>();
constructor(
private configService: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {
// Parse IP allowlist from environment
const ipAllowlist = this.configService.get<string>("SF_WEBHOOK_IP_ALLOWLIST");
this.allowedIps = ipAllowlist ? ipAllowlist.split(",").map(ip => ip.trim()) : [];
}
@Inject(Logger) private readonly logger: Logger,
private readonly cache: CacheService
) {}
canActivate(context: ExecutionContext): boolean {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<WebhookRequest>();
try {
// 1. Verify source IP if allowlist is configured
if (this.allowedIps.length > 0) {
this.verifySourceIp(request);
// Determine webhook type by signature header
const isWhmcs = Boolean(request.headers["x-whmcs-signature"]);
const isSalesforce = Boolean(request.headers["x-sf-signature"]);
if (!isWhmcs && !isSalesforce) {
throw new UnauthorizedException("Webhook signature is required");
}
// 1. Verify source IP if allowlist is configured (per vendor)
const ipAllowlistStr = this.configService.get<string>(
isWhmcs ? "WHMCS_WEBHOOK_IP_ALLOWLIST" : "SF_WEBHOOK_IP_ALLOWLIST"
);
const allowedIps = ipAllowlistStr
? ipAllowlistStr
.split(",")
.map((ip) => ip.trim())
.filter(Boolean)
: [];
if (allowedIps.length > 0) {
this.verifySourceIp(request, allowedIps);
}
// 2. Extract and verify required headers
const headers = this.extractHeaders(request);
const headers = this.extractHeaders(request, isSalesforce ? "salesforce" : "whmcs");
// 3. Verify timestamp (prevent replay attacks)
this.verifyTimestamp(headers.timestamp);
this.verifyTimestamp(headers.timestamp, isSalesforce);
// 4. Verify nonce (prevent duplicate processing)
this.verifyNonce(headers.nonce);
await this.verifyNonce(headers.nonce, isSalesforce);
// 5. Verify HMAC signature
this.verifyHmacSignature(request, headers.signature);
@ -53,8 +65,8 @@ export class EnhancedWebhookSignatureGuard implements CanActivate {
// Store metadata for logging/monitoring
request.webhookMetadata = {
sourceIp: request.ip || "unknown",
timestamp: new Date(headers.timestamp),
nonce: headers.nonce,
timestamp: headers.timestamp ? new Date(headers.timestamp) : (undefined as any),
nonce: headers.nonce as any,
signature: headers.signature,
};
@ -75,11 +87,11 @@ export class EnhancedWebhookSignatureGuard implements CanActivate {
}
}
private verifySourceIp(request: Request): void {
private verifySourceIp(request: Request, allowedIps: string[]): void {
const clientIp = request.ip || request.connection.remoteAddress || "unknown";
// Check if IP is in allowlist (simplified - in production use proper CIDR matching)
const isAllowed = this.allowedIps.some(allowedIp => {
const isAllowed = allowedIps.some((allowedIp) => {
if (allowedIp.includes("/")) {
// CIDR notation - implement proper CIDR matching
return this.isIpInCidr(clientIp, allowedIp);
@ -92,28 +104,33 @@ export class EnhancedWebhookSignatureGuard implements CanActivate {
}
}
private extractHeaders(request: Request) {
const signature =
(request.headers["x-sf-signature"] as string) ||
(request.headers["x-whmcs-signature"] as string);
const timestamp = request.headers["x-sf-timestamp"] as string;
const nonce = request.headers["x-sf-nonce"] as string;
private extractHeaders(request: Request, vendor: "salesforce" | "whmcs") {
const signature = (request.headers[
vendor === "salesforce" ? "x-sf-signature" : "x-whmcs-signature"
] as string) as string;
const timestamp = (request.headers[
vendor === "salesforce" ? "x-sf-timestamp" : ("x-whmcs-timestamp" as any)
] as string) as string | undefined;
const nonce = (request.headers[
vendor === "salesforce" ? "x-sf-nonce" : ("x-whmcs-nonce" as any)
] as string) as string | undefined;
if (!signature) {
throw new UnauthorizedException("Webhook signature is required");
}
if (!timestamp) {
throw new UnauthorizedException("Webhook timestamp is required");
}
if (!nonce) {
throw new UnauthorizedException("Webhook nonce is required");
if (vendor === "salesforce") {
if (!timestamp) throw new UnauthorizedException("Webhook timestamp is required");
if (!nonce) throw new UnauthorizedException("Webhook nonce is required");
}
return { signature, timestamp, nonce };
}
private verifyTimestamp(timestamp: string): void {
private verifyTimestamp(timestamp: string | undefined, required: boolean): void {
if (!timestamp) {
if (required) throw new UnauthorizedException("Invalid timestamp format");
return; // optional for WHMCS
}
const requestTime = new Date(timestamp).getTime();
const now = Date.now();
const tolerance = this.configService.get<number>("WEBHOOK_TIMESTAMP_TOLERANCE") || 300000; // 5 minutes
@ -127,17 +144,35 @@ export class EnhancedWebhookSignatureGuard implements CanActivate {
}
}
private verifyNonce(nonce: string): void {
// Check if nonce was already used
if (this.nonceStore.has(nonce)) {
throw new UnauthorizedException("Nonce already used (replay attack detected)");
private async verifyNonce(nonce: string | undefined, required: boolean): Promise<void> {
if (!nonce) {
if (required) throw new UnauthorizedException("Webhook nonce is required");
return; // optional for WHMCS
}
// Prefer Redis-backed nonce storage for distributed replay protection
const ttlMs = this.configService.get<number>("WEBHOOK_TIMESTAMP_TOLERANCE") ?? 300000; // 5m
const ttlSec = Math.max(1, Math.ceil(ttlMs / 1000));
const key = `webhook:nonce:${nonce}`;
// Add nonce to store
this.nonceStore.add(nonce);
// Clean up old nonces (in production, implement proper TTL with Redis)
this.cleanupOldNonces();
// If Redis is reachable, prefer it
try {
const exists = await this.cache.exists(key);
if (exists) {
throw new UnauthorizedException("Nonce already used (replay attack detected)");
}
await this.cache.set(key, 1, ttlSec);
return;
} catch (err) {
// If Redis fails, fall back to in-memory in dev
this.logger.warn("Redis unavailable for nonce storage, using in-memory fallback", {
error: err instanceof Error ? err.message : String(err),
});
if (this.nonceStore.has(nonce)) {
throw new UnauthorizedException("Nonce already used (replay attack detected)");
}
this.nonceStore.add(nonce);
this.cleanupOldNonces();
}
}
private verifyHmacSignature(request: Request, signature: string): void {

View File

@ -1,41 +0,0 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Request } from "express";
import crypto from "node:crypto";
@Injectable()
export class WebhookSignatureGuard implements CanActivate {
constructor(private configService: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
const signatureHeader =
(request.headers["x-whmcs-signature"] as string | undefined) ||
(request.headers["x-sf-signature"] as string | undefined);
if (!signatureHeader) {
throw new UnauthorizedException("Webhook signature is required");
}
// Get the appropriate secret based on the webhook type
const isWhmcs = Boolean(request.headers["x-whmcs-signature"]);
const secret = isWhmcs
? this.configService.get<string>("WHMCS_WEBHOOK_SECRET")
: this.configService.get<string>("SF_WEBHOOK_SECRET");
if (!secret) {
throw new UnauthorizedException("Webhook secret not configured");
}
// Verify signature
const payload = Buffer.from(JSON.stringify(request.body), "utf8");
const key = Buffer.from(secret, "utf8");
const expectedSignature = crypto.createHmac("sha256", key).update(payload).digest("hex");
if (signatureHeader !== expectedSignature) {
throw new UnauthorizedException("Invalid webhook signature");
}
return true;
}
}

View File

@ -11,7 +11,7 @@ import {
import { WebhooksService } from "./webhooks.service";
import { ApiTags, ApiOperation, ApiResponse, ApiHeader } from "@nestjs/swagger";
import { ThrottlerGuard } from "@nestjs/throttler";
import { WebhookSignatureGuard } from "./guards/webhook-signature.guard";
import { EnhancedWebhookSignatureGuard } from "./guards/enhanced-webhook-signature.guard";
import { Public } from "../auth/decorators/public.decorator";
@ApiTags("webhooks")
@ -23,7 +23,7 @@ export class WebhooksController {
@Post("whmcs")
@HttpCode(HttpStatus.OK)
@UseGuards(WebhookSignatureGuard)
@UseGuards(EnhancedWebhookSignatureGuard)
@ApiOperation({ summary: "WHMCS webhook endpoint" })
@ApiResponse({ status: 200, description: "Webhook processed successfully" })
@ApiResponse({ status: 400, description: "Invalid webhook data" })
@ -40,7 +40,7 @@ export class WebhooksController {
@Post("salesforce")
@HttpCode(HttpStatus.OK)
@UseGuards(WebhookSignatureGuard)
@UseGuards(EnhancedWebhookSignatureGuard)
@ApiOperation({ summary: "Salesforce webhook endpoint" })
@ApiResponse({ status: 200, description: "Webhook processed successfully" })
@ApiResponse({ status: 400, description: "Invalid webhook data" })

View File

@ -3,10 +3,12 @@ import { WebhooksController } from "./webhooks.controller";
import { WebhooksService } from "./webhooks.service";
import { VendorsModule } from "../vendors/vendors.module";
import { JobsModule } from "../jobs/jobs.module";
import { EnhancedWebhookSignatureGuard } from "./guards/enhanced-webhook-signature.guard";
@Module({
imports: [VendorsModule, JobsModule],
controllers: [WebhooksController],
providers: [WebhooksService],
providers: [WebhooksService, EnhancedWebhookSignatureGuard],
exports: [EnhancedWebhookSignatureGuard],
})
export class WebhooksModule {}

View File

@ -24,7 +24,7 @@ hasPaymentMethod(clientId: number): Promise<boolean>
```
### **2. Order Provisioning Service**
**File**: `/apps/bff/src/orders/services/order-provisioning.service.ts`
**File**: `/apps/bff/src/orders/services/order-fulfillment.service.ts`
- **Purpose**: Orchestrates the complete provisioning flow
- **Features**:
@ -38,7 +38,7 @@ hasPaymentMethod(clientId: number): Promise<boolean>
1. Validate SF Order → 2. Check Payment Method → 3. Map OrderItems → 4. Create WHMCS Order → 5. Accept WHMCS Order → 6. Update Salesforce
### **3. Separate Salesforce Provisioning Controller**
**File**: `/apps/bff/src/orders/controllers/salesforce-provisioning.controller.ts`
**File**: `/apps/bff/src/orders/controllers/order-fulfillment.controller.ts`
- **Purpose**: Dedicated controller for Salesforce webhook calls
- **Features**:
@ -64,7 +64,7 @@ hasPaymentMethod(clientId: number): Promise<boolean>
## 🔄 **The Complete Flow**
```
1. Salesforce Quick Action → POST /orders/{sfOrderId}/provision
1. Salesforce Quick Action → POST /orders/{sfOrderId}/fulfill
2. SalesforceProvisioningController (security validation)
@ -108,7 +108,7 @@ The system now properly handles the Salesforce → WHMCS mapping as specified in
- ✅ **AcceptOrder**: Provisions services and creates subscriptions
- ✅ **Payment validation**: Checks client has payment method
- ✅ **Error handling**: Updates Salesforce on failures
- ✅ **Idempotency**: Prevents duplicate provisioning
- ✅ **Idempotency**: Prevents duplicate fulfillment
## 🎯 **Benefits of New Architecture**
@ -116,7 +116,7 @@ The system now properly handles the Salesforce → WHMCS mapping as specified in
- **Single Responsibility**: Each service has one clear purpose
- **Separation of Concerns**: WHMCS logic separate from Salesforce logic
- **Testability**: Each service can be tested independently
- **Extensibility**: Easy to add new provisioning steps
- **Extensibility**: Easy to add new fulfillment steps
### **Security**:
- **Dedicated Controller**: Focused security for Salesforce webhooks
@ -124,7 +124,7 @@ The system now properly handles the Salesforce → WHMCS mapping as specified in
- **Clean Error Handling**: No sensitive data exposure
### **Reliability**:
- **Idempotency**: Safe retries for provisioning
- **Idempotency**: Safe retries for fulfillment
- **Comprehensive Logging**: Full audit trail
- **Error Recovery**: Proper Salesforce status updates on failures

View File

@ -10,7 +10,7 @@ I've cleanly integrated secure Salesforce-to-Portal communication into your exis
- **Integration**: Works with your existing Salesforce connection
### 2. **Secured Orders Controller**
- **Enhanced**: Existing `/orders/:sfOrderId/provision` endpoint
- **Enhanced**: `/orders/:sfOrderId/fulfill` endpoint
- **Added**: `EnhancedWebhookSignatureGuard` for HMAC signature validation
- **Added**: Proper API documentation and error handling
- **Security**: Timestamp, nonce, and idempotency key validation
@ -24,7 +24,7 @@ I've cleanly integrated secure Salesforce-to-Portal communication into your exis
## 🔄 The Simple Flow
```
1. Salesforce Quick Action → POST /orders/{sfOrderId}/provision (with HMAC security)
1. Salesforce Quick Action → POST /orders/{sfOrderId}/fulfill (with HMAC security)
2. Portal BFF validates → Provisions in WHMCS → DIRECTLY updates Salesforce Order
3. Customer polls Portal → Gets updated order status
```
@ -62,7 +62,7 @@ public class OrderProvisioningService {
String signature = generateHMACSignature(jsonPayload, WEBHOOK_SECRET);
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Portal_BFF/orders/' + orderId + '/provision');
req.setEndpoint('callout:Portal_BFF/orders/' + orderId + '/fulfill');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setHeader('X-SF-Signature', signature);

View File

@ -52,7 +52,7 @@ We require a Customer Number (SF Number) at signup and gate checkout on the pres
- `POST /orders` creates a Salesforce Order (Pending Review) and stores orchestration state in BFF. Portal shows “Awaiting review”.
5. Review & Provision (operator in Salesforce)
- Operator reviews/approves. Quick Action “Provision in WHMCS” invokes BFF `POST /orders/{sfOrderId}/provision`.
- Operator reviews/approves. Quick Action “Provision in WHMCS” invokes BFF `POST /orders/{sfOrderId}/fulfill`.
- BFF validates payment method, (for eSIM) calls activation API, then `AddOrder` and `AcceptOrder` in WHMCS, updates Salesforce Order fields/status.
6. Completion
@ -188,7 +188,7 @@ Endpoints (BFF)
- `GET /orders/:sfOrderId` (new)
- Returns orchestration status and relevant IDs; portal polls for updates.
- `POST /orders/:sfOrderId/provision` (new; invoked from Salesforce only)
- `POST /orders/:sfOrderId/fulfill` (Salesforce only)
- Auth: Named Credentials + signed headers (HMAC with timestamp/nonce) + IP allowlisting; require `Idempotency-Key`.
- Steps:
- Re-check payment method; if missing: set SF `Provisioning_Status__c=Failed`, `Error=Payment Method Missing`; return 409.
@ -277,7 +277,7 @@ We will build the BFF payload for WHMCS from these line records plus the Order h
### 3.3 Quick Action / Flow
- Quick Action “Provision in WHMCS” calls BFF `POST /orders/{sfOrderId}/provision` with headers:
- Quick Action “Provision in WHMCS” calls BFF `POST /orders/{sfOrderId}/fulfill` with headers:
- `Authorization` (Named Credentials)
- `Idempotency-Key` (UUID)
- `X-Timestamp`, `X-Nonce`, `X-Signature` (HMAC of method+path+timestamp+nonce+body)
@ -367,7 +367,7 @@ Prerequisites for WHMCS provisioning
1. Auth: require `sfNumber` in `SignupDto` and signup flow; lookup SF Account by Customer Number; align WHMCS custom field.
2. Billing: add `GET /billing/payment-methods/summary` and frontend gating.
3. Catalog UI: `/catalog` + product details pages.
4. Orders API: implement `POST /orders`, `GET /orders/:sfOrderId`, `POST /orders/:sfOrderId/provision`.
4. Orders API: implement `POST /orders`, `GET /orders/:sfOrderId`, `POST /orders/:sfOrderId/fulfill`.
5. Salesforce: fields, Quick Action/Flow, Named Credential + signing; LWC for status.
6. WHMCS: add wrappers for `AddOrder`, `AcceptOrder`, `GetPayMethods` (if not already exposed).
7. Observability: correlation IDs, metrics, alerts; webhook processing for cache busting (optional).
@ -421,7 +421,7 @@ Prerequisites for WHMCS provisioning
- `GET /orders/:sfOrderId`
- Response: `{ sfOrderId, status, whmcsOrderId?, whmcsServiceIds?: number[], lastUpdatedAt }`
- `POST /orders/:sfOrderId/provision` (SF only)
- `POST /orders/:sfOrderId/fulfill` (SF only)
- Request headers: `Authorization`, `Idempotency-Key`, `X-Timestamp`, `X-Nonce`, `X-Signature`
- Response: `{ status: 'Provisioned' | 'Failed', whmcsOrderId?, whmcsServiceIds?: number[], errorCode?, errorMessage? }`

View File

@ -48,7 +48,7 @@ This roadmap references `PORTAL-ORDERING-PROVISIONING.md` (complete flows and ar
8. BFF: Orders API
- `POST /orders`: create SF Order + OrderItems (snapshots: Quantity, UnitPrice, Billing_Cycle, ConfigOptions), status Pending Review; return `sfOrderId`.
- `GET /orders/:sfOrderId`: return orchestration status.
- `POST /orders/:sfOrderId/provision`: SF-only; recheck payment method; (eSIM) activate; WHMCS AddOrder → AcceptOrder; update SF with IDs/status; send emails.
- `POST /orders/:sfOrderId/fulfill`: SF-only; recheck payment method; (eSIM) activate; WHMCS AddOrder → AcceptOrder; update SF with IDs/status; send emails.
9. Salesforce: Quick Action/Flow
- Implement button action to call BFF with Named Credentials + HMAC; pass Idempotency-Key.
@ -78,7 +78,7 @@ This roadmap references `PORTAL-ORDERING-PROVISIONING.md` (complete flows and ar
14. Idempotency & resilience
- Cart hash idempotency for `POST /orders`.
- Idempotency-Key for `POST /orders/:sfOrderId/provision`.
- Idempotency-Key for `POST /orders/:sfOrderId/fulfill`.
- Include `sfOrderId` in WHMCS `notes` for duplicate protection.
15. Security reviews

View File

@ -0,0 +1,79 @@
# Provisioning Runbook (Salesforce → Portal → WHMCS)
This runbook helps operators diagnose issues in the order fulfillment path.
## Endpoints & Paths
- Salesforce Quick Action: POST `.../orders/{sfOrderId}/fulfill`
- Backend health: GET `/health`
## Required Env (Backend)
- `SF_LOGIN_URL`, `SF_CLIENT_ID`, `SF_USERNAME`
- `SF_PRIVATE_KEY_PATH` (prod: `/app/secrets/sf-private.key`)
- `SF_WEBHOOK_SECRET`
- `PORTAL_PRICEBOOK_ID`
- Optional: `WEBHOOK_TIMESTAMP_TOLERANCE` (ms)
## Common Symptoms and Fixes
- 401 Invalid signature
- Verify `SF_WEBHOOK_SECRET` matches Salesforce Named/External Credential
- Confirm Apex computes HMAC-SHA256 over raw JSON body
- Clocks skewed: adjust `WEBHOOK_TIMESTAMP_TOLERANCE` or fix server time
- 401 Nonce already used
- Replay blocked by Redis-backed nonce store. Ensure the Quick Action does not retry with identical nonce.
- If Redis is down, the system falls back to in-memory (dev only); restore Redis for cluster safety.
- 400 Missing fields (orderId/timestamp/nonce)
- Inspect Apex payload construction and headers
- Ensure `Idempotency-Key` header is unique per attempt
- 409 Payment method missing
- Customer has no WHMCS payment method
- Ask customer to add a payment method; retry fulfill
- WHMCS Add/Accept errors
- Check product mappings: `Product2.WH_Product_ID__c` and `Billing_Cycle__c`
- Backend logs show the item mapping report; fix missing mappings
- Salesforce status not updated
- Backend updates `Provisioning_Status__c` and `WHMCS_Order_ID__c` on success, `Provisioning_Error_*` on failure
- Verify connected app JWT config and that the API user has Order update permissions
## Verification Steps
1. In SF, create an Order with OrderItems
2. Trigger Quick Action; note `Idempotency-Key`
3. Check `/health`: database connected, environment correct
4. Tail logs; confirm steps: Activating → WHMCS add → WHMCS accept → Provisioned
5. Verify SF fields updated and WHMCS order/service IDs exist
## Logging Cheatsheet
- "Salesforce order fulfillment request received" — controller entry
- "Starting fulfillment orchestration" — orchestrator start
- Step logs: `validation`, `sf_status_update`, `order_details`, `mapping`, `whmcs_create`, `whmcs_accept`, `sf_success_update`
- On error: orchestrator updates SF with `Provisioning_Status__c='Failed'` and error code
## Security Notes
- HMAC and headers
- All inbound calls must include an HMAC signature.
- Salesforce must include `X-SF-Timestamp` and `X-SF-Nonce` headers.
- WHMCS timestamp/nonce are optional (validated if present).
- Env variables (backend)
- `SF_WEBHOOK_SECRET` (required)
- `WHMCS_WEBHOOK_SECRET` (required if WHMCS webhooks are enabled)
- `WEBHOOK_TIMESTAMP_TOLERANCE` (ms; default 300000)
- `SF_WEBHOOK_IP_ALLOWLIST` (CSV of IP/CIDR; optional)
- `WHMCS_WEBHOOK_IP_ALLOWLIST` (CSV of IP/CIDR; optional)
- Replay protection
- Redis-backed nonce store blocks replays (Salesforce required; WHMCS optional).
- If Redis is down, a local in-memory fallback is used (dev only). Restore Redis in prod.
- Health endpoint
- `/health` includes `integrations.redis` probe to confirm nonce store availability.

View File

@ -9,7 +9,7 @@ This guide focuses specifically on **secure communication between Salesforce and
```
1. Customer places order → Portal creates Salesforce Order (Status: "Pending Review")
2. Salesforce operator reviews → Clicks "Provision in WHMCS" Quick Action
3. Salesforce calls Portal BFF → POST /orders/{sfOrderId}/provision
3. Salesforce calls Portal BFF → POST /orders/{sfOrderId}/fulfill
4. Portal BFF provisions in WHMCS → Updates Salesforce Order status
5. Customer sees updated status in Portal
```
@ -20,7 +20,7 @@ This guide focuses specifically on **secure communication between Salesforce and
Your existing architecture already handles this securely via the **Quick Action** that calls your BFF endpoint:
- **Endpoint**: `POST /orders/{sfOrderId}/provision`
- **Endpoint**: `POST /orders/{sfOrderId}/fulfill`
- **Authentication**: Named Credentials + HMAC signature
- **Security**: IP allowlisting, idempotency keys, signed headers
@ -29,19 +29,19 @@ Your existing architecture already handles this securely via the **Quick Action*
Use your existing `EnhancedWebhookSignatureGuard` for the provisioning endpoint:
```typescript
// apps/bff/src/orders/orders.controller.ts
@Post(':sfOrderId/provision')
// apps/bff/src/orders/controllers/order-fulfillment.controller.ts
@Post(':sfOrderId/fulfill')
@UseGuards(EnhancedWebhookSignatureGuard)
@ApiHeader({ name: "X-SF-Signature", description: "Salesforce HMAC signature" })
@ApiHeader({ name: "X-SF-Timestamp", description: "Request timestamp" })
@ApiHeader({ name: "X-SF-Nonce", description: "Unique nonce" })
@ApiHeader({ name: "Idempotency-Key", description: "Idempotency key" })
async provisionOrder(
async fulfillOrder(
@Param('sfOrderId') sfOrderId: string,
@Body() payload: ProvisionOrderRequest,
@Body() payload: { orderId: string; timestamp: string; nonce: string },
@Headers('idempotency-key') idempotencyKey: string
) {
return await this.orderOrchestrator.provisionOrder(sfOrderId, payload, idempotencyKey);
return await this.orderFulfillmentService.fulfillOrder(sfOrderId, payload, idempotencyKey);
}
```
@ -66,7 +66,7 @@ public class OrderProvisioningService {
// Make secure HTTP call
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Portal_BFF/orders/' + orderId + '/provision');
req.setEndpoint('callout:Portal_BFF/orders/' + orderId + '/fulfill');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setHeader('X-SF-Signature', signature);
@ -177,7 +177,7 @@ export class OrderStatusUpdateService {
```typescript
// In your existing OrderOrchestrator service
async provisionOrder(sfOrderId: string, payload: any, idempotencyKey: string) {
async fulfillOrder(sfOrderId: string, payload: any, idempotencyKey: string) {
try {
// Update status to "Activating"
await this.orderStatusUpdateService.updateOrderStatus(sfOrderId, 'Activating');
@ -317,7 +317,7 @@ export class OrderProvisioningMonitoringService {
describe('Order Provisioning Security', () => {
it('should reject requests without valid HMAC signature', async () => {
const response = await request(app)
.post('/orders/test-order-id/provision')
.post('/orders/test-order-id/fulfill')
.send({ orderId: 'test-order-id' })
.expect(401);
});
@ -328,7 +328,7 @@ describe('Order Provisioning Security', () => {
const signature = generateHmacSignature(JSON.stringify(payload));
const response = await request(app)
.post('/orders/test-order-id/provision')
.post('/orders/test-order-id/fulfill')
.set('X-SF-Signature', signature)
.set('X-SF-Timestamp', oldTimestamp)
.send(payload)

View File

@ -11,7 +11,7 @@ Portal Customer → Places Order → Salesforce Order (Pending Review)
Salesforce Operator → Reviews → Clicks "Provision in WHMCS" Quick Action
Salesforce → Calls Portal BFF → `/orders/{sfOrderId}/provision`
Salesforce → Calls Portal BFF → `/orders/{sfOrderId}/fulfill`
Portal BFF → Provisions in WHMCS → Updates Salesforce Order Status
@ -26,7 +26,7 @@ Based on your architecture, the **order provisioning flow** uses direct HTTPS ca
**Salesforce → Portal BFF Flow:**
1. **Salesforce Quick Action** calls `POST /orders/{sfOrderId}/provision`
1. **Salesforce Quick Action** calls `POST /orders/{sfOrderId}/fulfill`
2. **Portal BFF** processes the provisioning request
3. **Optional: Portal → Salesforce** status updates via webhook
@ -67,7 +67,7 @@ public class PortalWebhookService {
// Make secure HTTP call
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Portal_BFF/orders/' + orderId + '/provision');
req.setEndpoint('callout:Portal_BFF/orders/' + orderId + '/fulfill');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setHeader('X-SF-Signature', signature);
@ -131,19 +131,19 @@ export class SalesforceStatusUpdateService {
### Enhanced Order Provisioning Endpoint
Your portal BFF should implement the `/orders/{sfOrderId}/provision` endpoint with these security measures:
Your portal BFF should implement the `/orders/{sfOrderId}/fulfill` endpoint with these security measures:
```typescript
// Enhanced order provisioning endpoint
@Post('orders/:sfOrderId/provision')
// Enhanced order fulfillment endpoint
@Post('orders/:sfOrderId/fulfill')
@UseGuards(EnhancedWebhookSignatureGuard)
async provisionOrder(
async fulfillOrder(
@Param('sfOrderId') sfOrderId: string,
@Body() payload: ProvisionOrderRequest,
@Headers('idempotency-key') idempotencyKey: string
) {
// Your existing provisioning logic
return await this.orderOrchestrator.provisionOrder(sfOrderId, payload, idempotencyKey);
// Your existing fulfillment logic
return await this.orderFulfillmentService.fulfillOrder(sfOrderId, payload, idempotencyKey);
}
```

View File

@ -5,7 +5,7 @@
```
1. Customer places order → Portal creates Salesforce Order (Pending Review)
2. Salesforce operator → Clicks "Provision in WHMCS" Quick Action
3. Salesforce → Calls Portal BFF → POST /orders/{sfOrderId}/provision
3. Salesforce → Calls Portal BFF → POST /orders/{sfOrderId}/fulfill
4. Portal BFF → Provisions in WHMCS → DIRECTLY updates Salesforce Order (via existing SF API)
5. Customer → Polls Portal for status updates
```
@ -35,7 +35,8 @@ public class OrderProvisioningService {
// Call Portal BFF
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Portal_BFF/orders/' + orderId + '/provision');
// Use the single canonical path '/fulfill'
req.setEndpoint('callout:Portal_BFF/orders/' + orderId + '/fulfill');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setHeader('X-SF-Signature', signature);
@ -84,19 +85,28 @@ public class OrderProvisioningService {
### Enhanced Security for Provisioning Endpoint
```typescript
// apps/bff/src/orders/orders.controller.ts
@Post(':sfOrderId/provision')
// apps/bff/src/orders/controllers/order-fulfillment.controller.ts
@Post(':sfOrderId/fulfill')
@UseGuards(EnhancedWebhookSignatureGuard) // Your existing guard
@ApiOperation({ summary: "Provision order from Salesforce" })
@ApiOperation({ summary: "Fulfill order from Salesforce" })
async provisionOrder(
@Param('sfOrderId') sfOrderId: string,
@Body() payload: { orderId: string; timestamp: string; nonce: string },
@Headers('idempotency-key') idempotencyKey: string
) {
return await this.orderOrchestrator.provisionOrder(sfOrderId, payload, idempotencyKey);
return await this.orderFulfillmentService.fulfillOrder(sfOrderId, payload, idempotencyKey);
}
```
## 3. Production Env Notes (Plesk)
- Backend reads environment from the Plesk env file, not from repo `.env`:
- `compose-plesk.yaml``env_file: /var/www/vhosts/.../env/portal-backend.env`
- Mount secrets inside the container at `/app/secrets` and set:
- `SF_PRIVATE_KEY_PATH=/app/secrets/sf-private.key`
- `SF_LOGIN_URL`, `SF_CLIENT_ID`, `SF_USERNAME`, `SF_WEBHOOK_SECRET`
- The backend validates the private key path to be under `./secrets` (dev) or `/app/secrets` (prod).
### Order Orchestrator (Direct Salesforce Updates)
```typescript