diff --git a/PLESK_DEPLOYMENT.md b/PLESK_DEPLOYMENT.md index 81c51c2c..3f8748f5 100644 --- a/PLESK_DEPLOYMENT.md +++ b/PLESK_DEPLOYMENT.md @@ -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 diff --git a/README.md b/README.md index fa9e194e..88915788 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/apps/bff/src/health/health.controller.ts b/apps/bff/src/health/health.controller.ts index 205483c8..379da864 100644 --- a/apps/bff/src/health/health.controller.ts +++ b/apps/bff/src/health/health.controller.ts @@ -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", diff --git a/apps/bff/src/orders/controllers/order-fulfillment.controller.ts b/apps/bff/src/orders/controllers/order-fulfillment.controller.ts index 64da5e82..db87b12a 100644 --- a/apps/bff/src/orders/controllers/order-fulfillment.controller.ts +++ b/apps/bff/src/orders/controllers/order-fulfillment.controller.ts @@ -145,4 +145,6 @@ export class OrderFulfillmentController { throw error; } } + + // Removed /provision alias to avoid confusion — use /fulfill only } diff --git a/apps/bff/src/orders/orders.module.ts b/apps/bff/src/orders/orders.module.ts index a53fb65b..0d56525b 100644 --- a/apps/bff/src/orders/orders.module.ts +++ b/apps/bff/src/orders/orders.module.ts @@ -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) diff --git a/apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts b/apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts index 8773304a..07b32cf5 100644 --- a/apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts +++ b/apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts @@ -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 { - 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 */ diff --git a/apps/bff/src/orders/services/order-fulfillment-validator.service.ts b/apps/bff/src/orders/services/order-fulfillment-validator.service.ts index 94abe5f3..7912f19f 100644 --- a/apps/bff/src/orders/services/order-fulfillment-validator.service.ts +++ b/apps/bff/src/orders/services/order-fulfillment-validator.service.ts @@ -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("WEBHOOK_TIMESTAMP_TOLERANCE") ?? 5 * 60 * 1000; // default 5m if (Math.abs(now - requestTime) > maxAge) { throw new BadRequestException("Request timestamp is too old"); diff --git a/apps/bff/src/vendors/salesforce/services/salesforce-account.service.ts b/apps/bff/src/vendors/salesforce/services/salesforce-account.service.ts index e91a9ff4..abe2d34f 100644 --- a/apps/bff/src/vendors/salesforce/services/salesforce-account.service.ts +++ b/apps/bff/src/vendors/salesforce/services/salesforce-account.service.ts @@ -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; 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; 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; 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; return result.totalSize > 0 ? result.records[0] : null; } catch (error) { diff --git a/apps/bff/src/vendors/salesforce/services/salesforce-case.service.ts b/apps/bff/src/vendors/salesforce/services/salesforce-case.service.ts index dde5e4f3..028afcc4 100644 --- a/apps/bff/src/vendors/salesforce/services/salesforce-case.service.ts +++ b/apps/bff/src/vendors/salesforce/services/salesforce-case.service.ts @@ -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; 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; 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) => Promise; + create: (data: Record) => Promise; }; 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) => Promise; + create: (data: Record) => Promise; }; 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; return createdCase.records[0]; } diff --git a/apps/bff/src/vendors/salesforce/services/salesforce-connection.service.ts b/apps/bff/src/vendors/salesforce/services/salesforce-connection.service.ts index 9f2ec632..20603d54 100644 --- a/apps/bff/src/vendors/salesforce/services/salesforce-connection.service.ts +++ b/apps/bff/src/vendors/salesforce/services/salesforce-connection.service.ts @@ -12,11 +12,6 @@ export interface SalesforceSObjectApi { update?: (data: Record & { Id: string }) => Promise; } -interface _SalesforceRetryableSObjectApi extends SalesforceSObjectApi { - create: (data: Record) => Promise<{ id?: string }>; - update?: (data: Record & { Id: string }) => Promise; -} - @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); } diff --git a/apps/bff/src/webhooks/guards/enhanced-webhook-signature.guard.ts b/apps/bff/src/webhooks/guards/enhanced-webhook-signature.guard.ts index 8aa241f0..69be0a49 100644 --- a/apps/bff/src/webhooks/guards/enhanced-webhook-signature.guard.ts +++ b/apps/bff/src/webhooks/guards/enhanced-webhook-signature.guard.ts @@ -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(); // 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(); constructor( private configService: ConfigService, - @Inject(Logger) private readonly logger: Logger - ) { - // Parse IP allowlist from environment - const ipAllowlist = this.configService.get("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 { const request = context.switchToHttp().getRequest(); 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( + 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("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 { + 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("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 { diff --git a/apps/bff/src/webhooks/guards/webhook-signature.guard.ts b/apps/bff/src/webhooks/guards/webhook-signature.guard.ts deleted file mode 100644 index a52ce864..00000000 --- a/apps/bff/src/webhooks/guards/webhook-signature.guard.ts +++ /dev/null @@ -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(); - 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("WHMCS_WEBHOOK_SECRET") - : this.configService.get("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; - } -} diff --git a/apps/bff/src/webhooks/webhooks.controller.ts b/apps/bff/src/webhooks/webhooks.controller.ts index ea04c3e9..1ba81a2f 100644 --- a/apps/bff/src/webhooks/webhooks.controller.ts +++ b/apps/bff/src/webhooks/webhooks.controller.ts @@ -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" }) diff --git a/apps/bff/src/webhooks/webhooks.module.ts b/apps/bff/src/webhooks/webhooks.module.ts index 47b37325..57b4bf77 100644 --- a/apps/bff/src/webhooks/webhooks.module.ts +++ b/apps/bff/src/webhooks/webhooks.module.ts @@ -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 {} diff --git a/docs/CLEAN-ARCHITECTURE-SUMMARY.md b/docs/CLEAN-ARCHITECTURE-SUMMARY.md index 3b5744ea..f903542e 100644 --- a/docs/CLEAN-ARCHITECTURE-SUMMARY.md +++ b/docs/CLEAN-ARCHITECTURE-SUMMARY.md @@ -24,7 +24,7 @@ hasPaymentMethod(clientId: number): Promise ``` ### **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 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 ## 🔄 **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 diff --git a/docs/IMPLEMENTATION-SUMMARY.md b/docs/IMPLEMENTATION-SUMMARY.md index 8e85b9f2..d98b61fc 100644 --- a/docs/IMPLEMENTATION-SUMMARY.md +++ b/docs/IMPLEMENTATION-SUMMARY.md @@ -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); diff --git a/docs/PORTAL-ORDERING-PROVISIONING.md b/docs/PORTAL-ORDERING-PROVISIONING.md index de3e7152..e2e73f8e 100644 --- a/docs/PORTAL-ORDERING-PROVISIONING.md +++ b/docs/PORTAL-ORDERING-PROVISIONING.md @@ -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? }` diff --git a/docs/PORTAL-ROADMAP.md b/docs/PORTAL-ROADMAP.md index ade9a56c..d567667b 100644 --- a/docs/PORTAL-ROADMAP.md +++ b/docs/PORTAL-ROADMAP.md @@ -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 diff --git a/docs/RUNBOOK_PROVISIONING.md b/docs/RUNBOOK_PROVISIONING.md new file mode 100644 index 00000000..7b57c340 --- /dev/null +++ b/docs/RUNBOOK_PROVISIONING.md @@ -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. diff --git a/docs/SALESFORCE-ORDER-COMMUNICATION.md b/docs/SALESFORCE-ORDER-COMMUNICATION.md index 54ef64bd..6c49ffd0 100644 --- a/docs/SALESFORCE-ORDER-COMMUNICATION.md +++ b/docs/SALESFORCE-ORDER-COMMUNICATION.md @@ -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) diff --git a/docs/SALESFORCE-PORTAL-SECURITY-GUIDE.md b/docs/SALESFORCE-PORTAL-SECURITY-GUIDE.md index ca397148..d001ab6f 100644 --- a/docs/SALESFORCE-PORTAL-SECURITY-GUIDE.md +++ b/docs/SALESFORCE-PORTAL-SECURITY-GUIDE.md @@ -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); } ``` diff --git a/docs/SALESFORCE-PORTAL-SIMPLE-GUIDE.md b/docs/SALESFORCE-PORTAL-SIMPLE-GUIDE.md index 2ab5dcea..a88df698 100644 --- a/docs/SALESFORCE-PORTAL-SIMPLE-GUIDE.md +++ b/docs/SALESFORCE-PORTAL-SIMPLE-GUIDE.md @@ -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