mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: dev and staging bypass
This commit is contained in:
@@ -1,4 +1,10 @@
|
|||||||
# Copy to .env for local/docker compose (not committed).
|
# Copy to .env for local/docker compose (not committed).
|
||||||
|
|
||||||
|
# Set to "dev" or "staging" to bypass OTP (fixed code 000000 also accepted),
|
||||||
|
# payment (invoice auto-marked paid on initiate, no gateway call) and Fayda
|
||||||
|
# (canned verified profile, no eSignet call). Leave unset in production.
|
||||||
|
ENV=
|
||||||
|
|
||||||
PORT=3001
|
PORT=3001
|
||||||
# @tria-plc/auditlog's client interceptor stamps every AuditLog row's
|
# @tria-plc/auditlog's client interceptor stamps every AuditLog row's
|
||||||
# `application` from this env var directly, bypassing MezgebModule.forRoot's
|
# `application` from this env var directly, bypassing MezgebModule.forRoot's
|
||||||
|
|||||||
13
apps/edr-freight-api/src/common/dev-bypass.util.ts
Normal file
13
apps/edr-freight-api/src/common/dev-bypass.util.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Dev/staging bypass gate for OTP, payment and Fayda verification.
|
||||||
|
*
|
||||||
|
* Gated purely on `ENV` (dev|staging) — never on NODE_ENV, so it can't be
|
||||||
|
* mistaken for a prod-vs-non-prod switch. `ENV` is simply left unset in
|
||||||
|
* production, so this is always false there.
|
||||||
|
*/
|
||||||
|
export function isBypassEnv(): boolean {
|
||||||
|
return ["dev", "staging"].includes(process.env.ENV ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fixed code accepted in addition to the real one when isBypassEnv(). */
|
||||||
|
export const DEV_BYPASS_OTP = "000000";
|
||||||
@@ -8,6 +8,7 @@ import { OtpRepository } from "./otp.repository";
|
|||||||
|
|
||||||
import { NotificationsService } from "../notifications/notifications.service";
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
import { EmailClientService } from "../notifications/email-client.service";
|
import { EmailClientService } from "../notifications/email-client.service";
|
||||||
|
import { isBypassEnv, DEV_BYPASS_OTP } from "../../common/dev-bypass.util";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a code goes. At least one of phone/email must be set — enforced by the
|
* Where a code goes. At least one of phone/email must be set — enforced by the
|
||||||
@@ -146,6 +147,21 @@ export class OtpService {
|
|||||||
`otp.issue channels=${channels.join("+")} target=${label} action=${rotated ? "rotate" : "create"}`,
|
`otp.issue channels=${channels.join("+")} target=${label} action=${rotated ? "rotate" : "create"}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Dev/staging only: the row above still exists (so a real code would
|
||||||
|
// still verify), but skip the real SMS/email send — no carrier cost, no
|
||||||
|
// dependency on RabbitMQ/the mail relay being up. Verify with the fixed
|
||||||
|
// DEV_BYPASS_OTP code instead of whatever landed in the row.
|
||||||
|
if (isBypassEnv()) {
|
||||||
|
this.logger.warn(
|
||||||
|
`otp.dispatch.bypassed target=${label} — dev/staging, no real SMS/email sent (verify with ${DEV_BYPASS_OTP})`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
delivered: true,
|
||||||
|
message: "OTP sent successfully",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE: do NOT reset the brute-force attempt counter on send. Clearing it
|
// NOTE: do NOT reset the brute-force attempt counter on send. Clearing it
|
||||||
// here let an attacker wipe the per-target guess budget just by calling
|
// here let an attacker wipe the per-target guess budget just by calling
|
||||||
// /otp/send between guesses. The counter is cleared only when the code is
|
// /otp/send between guesses. The counter is cleared only when the code is
|
||||||
@@ -394,7 +410,10 @@ export class OtpService {
|
|||||||
|
|
||||||
// invalid otp — per-target attempt cap so a 6-digit code can't be
|
// invalid otp — per-target attempt cap so a 6-digit code can't be
|
||||||
// brute-forced within its TTL; the code is burned once the budget is spent.
|
// brute-forced within its TTL; the code is burned once the budget is spent.
|
||||||
if (otpData.otp !== otp) {
|
// Dev/staging only: a fixed code verifies any pending OTP row without
|
||||||
|
// knowing the real one — the row still has to exist (sendOtp still runs).
|
||||||
|
const bypassed = isBypassEnv() && otp === DEV_BYPASS_OTP;
|
||||||
|
if (otpData.otp !== otp && !bypassed) {
|
||||||
const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
|
const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
|
||||||
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
|
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
|
||||||
await this.otpRepository.deleteOtp(otpData);
|
await this.otpRepository.deleteOtp(otpData);
|
||||||
@@ -482,7 +501,10 @@ export class OtpService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (otpData.otp !== otp) {
|
// Dev/staging only: a fixed code verifies any pending OTP row without
|
||||||
|
// knowing the real one — the row still has to exist (sendOtp still runs).
|
||||||
|
const bypassed = isBypassEnv() && otp === DEV_BYPASS_OTP;
|
||||||
|
if (otpData.otp !== otp && !bypassed) {
|
||||||
const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
|
const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
|
||||||
|
|
||||||
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
|
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
IntentStatusDto,
|
IntentStatusDto,
|
||||||
PaymentPlatformDto,
|
PaymentPlatformDto,
|
||||||
} from "./payments.dto";
|
} from "./payments.dto";
|
||||||
|
import { isBypassEnv } from "../../common/dev-bypass.util";
|
||||||
|
|
||||||
/** Everything the gateway needs to open an intent. Amount/currency are supplied by
|
/** Everything the gateway needs to open an intent. Amount/currency are supplied by
|
||||||
* the caller (billing) — this service never derives them from a domain record. */
|
* the caller (billing) — this service never derives them from a domain record. */
|
||||||
@@ -256,34 +257,52 @@ export class PaymentService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = await this.paymentClient.initiate({
|
// Dev/staging only: skip the real gateway call entirely and report an
|
||||||
service: PaymentServiceEnum.FREIGHT,
|
// immediate SUCCEEDED snapshot — everything below (upsert, settle,
|
||||||
referenceType: PaymentReferenceType.SHIPMENT,
|
// billing notify) runs exactly as it would for a real synchronous
|
||||||
referenceId: input.referenceId,
|
// provider success.
|
||||||
orderRef: input.orderRef,
|
const snapshot: PaymentIntentSnapshot = isBypassEnv()
|
||||||
// CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was
|
? {
|
||||||
// debited against the intent amount, so the dev shortcut would break it.
|
intentId: `bypass-${input.referenceId}`,
|
||||||
// CAC bank rejects amounts below 10 (DJF bounds 10–100,000), so its dev
|
service: PaymentServiceEnum.FREIGHT,
|
||||||
// shortcut floor is 10, not 1.
|
referenceType: PaymentReferenceType.SHIPMENT,
|
||||||
// amountMinor: isCbeBill
|
referenceId: input.referenceId,
|
||||||
// ? input.amountMinor
|
merchantOrderId: input.orderRef,
|
||||||
// : input.method === ProviderMethod.CAC_BANK
|
provider: input.method as ProviderMethod,
|
||||||
// ? 10
|
status: ProviderPaymentStatus.SUCCEEDED,
|
||||||
// : 1,
|
amountMinor: input.amountMinor,
|
||||||
amountMinor: input.amountMinor,
|
currency: input.currency,
|
||||||
currency: input.currency,
|
providerTxnId: `bypass-${input.referenceId}`,
|
||||||
provider: input.method as ProviderMethod,
|
paidAt: new Date().toISOString(),
|
||||||
platform: input.platform,
|
}
|
||||||
payerAccount: input.payerAccount,
|
: await this.paymentClient.initiate({
|
||||||
payerName: input.payerName,
|
service: PaymentServiceEnum.FREIGHT,
|
||||||
expiresAt: input.expiresAt,
|
referenceType: PaymentReferenceType.SHIPMENT,
|
||||||
// bookingId lets the success page ack the redirect (→ PAYMENT_PROCESSING).
|
referenceId: input.referenceId,
|
||||||
returnUrl:
|
orderRef: input.orderRef,
|
||||||
input.returnUrl ??
|
// CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was
|
||||||
`https://edrfreight.triaplc.com/payment/success?bookingId=${encodeURIComponent(input.referenceId)}`,
|
// debited against the intent amount, so the dev shortcut would break it.
|
||||||
failureUrl:
|
// CAC bank rejects amounts below 10 (DJF bounds 10–100,000), so its dev
|
||||||
input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
|
// shortcut floor is 10, not 1.
|
||||||
});
|
// amountMinor: isCbeBill
|
||||||
|
// ? input.amountMinor
|
||||||
|
// : input.method === ProviderMethod.CAC_BANK
|
||||||
|
// ? 10
|
||||||
|
// : 1,
|
||||||
|
amountMinor: input.amountMinor,
|
||||||
|
currency: input.currency,
|
||||||
|
provider: input.method as ProviderMethod,
|
||||||
|
platform: input.platform,
|
||||||
|
payerAccount: input.payerAccount,
|
||||||
|
payerName: input.payerName,
|
||||||
|
expiresAt: input.expiresAt,
|
||||||
|
// bookingId lets the success page ack the redirect (→ PAYMENT_PROCESSING).
|
||||||
|
returnUrl:
|
||||||
|
input.returnUrl ??
|
||||||
|
`https://edrfreight.triaplc.com/payment/success?bookingId=${encodeURIComponent(input.referenceId)}`,
|
||||||
|
failureUrl:
|
||||||
|
input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
|
||||||
|
});
|
||||||
|
|
||||||
const immediateSuccess =
|
const immediateSuccess =
|
||||||
snapshot.status === ProviderPaymentStatus.SUCCEEDED;
|
snapshot.status === ProviderPaymentStatus.SUCCEEDED;
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ import {
|
|||||||
NormalizedFaydaUserInfo,
|
NormalizedFaydaUserInfo,
|
||||||
VerifaydaPurpose,
|
VerifaydaPurpose,
|
||||||
} from './verifayda.types';
|
} from './verifayda.types';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { isBypassEnv } from '../../common/dev-bypass.util';
|
||||||
|
|
||||||
|
/** Sentinel `code` that skips the real eSignet exchange in dev/staging. */
|
||||||
|
export const DEV_BYPASS_FAYDA_CODE = 'DEV_BYPASS';
|
||||||
|
|
||||||
export interface StartVerificationInput {
|
export interface StartVerificationInput {
|
||||||
purpose: VerifaydaPurpose;
|
purpose: VerifaydaPurpose;
|
||||||
@@ -143,6 +148,26 @@ export class VerifaydaService {
|
|||||||
async completeVerification(
|
async completeVerification(
|
||||||
query: VerifaydaCallbackDto,
|
query: VerifaydaCallbackDto,
|
||||||
): Promise<CompleteVerificationResult> {
|
): Promise<CompleteVerificationResult> {
|
||||||
|
// Dev/staging only: caller sends the sentinel code instead of a real
|
||||||
|
// eSignet redirect — skip the token exchange/session entirely and hand
|
||||||
|
// back a canned VERIFY result. `sub` is unique per call so binding both
|
||||||
|
// owner and PoA in the same bypass session doesn't collide.
|
||||||
|
if (isBypassEnv() && query.code === DEV_BYPASS_FAYDA_CODE) {
|
||||||
|
this.logger.warn('Fayda verification BYPASSED (dev/staging)');
|
||||||
|
return {
|
||||||
|
purpose: 'VERIFY',
|
||||||
|
verified: true,
|
||||||
|
sub: `dev-bypass-${randomUUID()}`,
|
||||||
|
fullName: 'Dev Bypass User',
|
||||||
|
email: 'dev-bypass@example.com',
|
||||||
|
phoneNumber: '+251900000000',
|
||||||
|
birthdate: '1990-01-01',
|
||||||
|
gender: 'M',
|
||||||
|
address: 'Dev Bypass Address',
|
||||||
|
userDataSaved: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (query.error) {
|
if (query.error) {
|
||||||
this.logger.warn(`Fayda callback returned error: ${query.error}`);
|
this.logger.warn(`Fayda callback returned error: ${query.error}`);
|
||||||
if (query.state) {
|
if (query.state) {
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ import {
|
|||||||
type IdentitySubject,
|
type IdentitySubject,
|
||||||
type IdentityVerificationState,
|
type IdentityVerificationState,
|
||||||
} from "@/services/verifayda.service";
|
} from "@/services/verifayda.service";
|
||||||
|
import { isBypassEnv } from "@/utils/dev-bypass";
|
||||||
|
|
||||||
|
/** Sentinel code that skips the real eSignet exchange in dev/staging (see api's DEV_BYPASS_FAYDA_CODE). */
|
||||||
|
const DEV_BYPASS_FAYDA_CODE = "DEV_BYPASS";
|
||||||
|
|
||||||
interface FaydaVerifyPanelProps {
|
interface FaydaVerifyPanelProps {
|
||||||
subject: IdentitySubject;
|
subject: IdentitySubject;
|
||||||
@@ -80,6 +84,23 @@ export default function FaydaVerifyPanel({
|
|||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
// Dev/staging only: send the tab straight to /fayda/callback with the
|
||||||
|
// sentinel code instead of round-tripping through eSignet — that page's
|
||||||
|
// existing completeIdentity()/navigate-back logic runs unchanged.
|
||||||
|
if (isBypassEnv()) {
|
||||||
|
stashPendingVerification({
|
||||||
|
subject,
|
||||||
|
returnTo:
|
||||||
|
window.location.pathname +
|
||||||
|
window.location.search +
|
||||||
|
window.location.hash,
|
||||||
|
});
|
||||||
|
window.location.assign(
|
||||||
|
`/fayda/callback?code=${DEV_BYPASS_FAYDA_CODE}&state=bypass`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const authorizationUrl = await verifaydaService.start();
|
const authorizationUrl = await verifaydaService.start();
|
||||||
// Record who is being verified and where to come back to before the tab
|
// Record who is being verified and where to come back to before the tab
|
||||||
// leaves — /fayda/callback has no other way to know either.
|
// leaves — /fayda/callback has no other way to know either.
|
||||||
|
|||||||
4
apps/edr-freight-web/portal/src/utils/dev-bypass.ts
Normal file
4
apps/edr-freight-web/portal/src/utils/dev-bypass.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/** True when VITE_ENV is "dev" or "staging" — mirrors the API's ENV gate. */
|
||||||
|
export function isBypassEnv(): boolean {
|
||||||
|
return ["dev", "staging"].includes(import.meta.env.VITE_ENV ?? "");
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_POSTHOG_KEY?: string;
|
readonly VITE_POSTHOG_KEY?: string;
|
||||||
/** Self-hosted PostHog instance URL. */
|
/** Self-hosted PostHog instance URL. */
|
||||||
readonly VITE_POSTHOG_HOST?: string;
|
readonly VITE_POSTHOG_HOST?: string;
|
||||||
|
/** "dev" | "staging" — enables the OTP/payment/Fayda bypass. Unset in prod. */
|
||||||
|
readonly VITE_ENV?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
Reference in New Issue
Block a user