feat: dev and staging bypass

This commit is contained in:
Nathnael
2026-08-15 08:07:06 +00:00
parent 6c4969f3fd
commit 69805315d9
8 changed files with 142 additions and 30 deletions

View File

@@ -1,4 +1,10 @@
# 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
# @tria-plc/auditlog's client interceptor stamps every AuditLog row's
# `application` from this env var directly, bypassing MezgebModule.forRoot's

View 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";

View File

@@ -8,6 +8,7 @@ import { OtpRepository } from "./otp.repository";
import { NotificationsService } from "../notifications/notifications.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
@@ -146,6 +147,21 @@ export class OtpService {
`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
// 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
@@ -394,7 +410,10 @@ export class OtpService {
// 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.
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;
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
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;
if (attempts >= this.MAX_ACTION_ATTEMPTS) {

View File

@@ -31,6 +31,7 @@ import {
IntentStatusDto,
PaymentPlatformDto,
} from "./payments.dto";
import { isBypassEnv } from "../../common/dev-bypass.util";
/** 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. */
@@ -256,34 +257,52 @@ export class PaymentService {
);
}
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT,
referenceType: PaymentReferenceType.SHIPMENT,
referenceId: input.referenceId,
orderRef: input.orderRef,
// 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.
// CAC bank rejects amounts below 10 (DJF bounds 10100,000), so its dev
// 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",
});
// Dev/staging only: skip the real gateway call entirely and report an
// immediate SUCCEEDED snapshot — everything below (upsert, settle,
// billing notify) runs exactly as it would for a real synchronous
// provider success.
const snapshot: PaymentIntentSnapshot = isBypassEnv()
? {
intentId: `bypass-${input.referenceId}`,
service: PaymentServiceEnum.FREIGHT,
referenceType: PaymentReferenceType.SHIPMENT,
referenceId: input.referenceId,
merchantOrderId: input.orderRef,
provider: input.method as ProviderMethod,
status: ProviderPaymentStatus.SUCCEEDED,
amountMinor: input.amountMinor,
currency: input.currency,
providerTxnId: `bypass-${input.referenceId}`,
paidAt: new Date().toISOString(),
}
: await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT,
referenceType: PaymentReferenceType.SHIPMENT,
referenceId: input.referenceId,
orderRef: input.orderRef,
// 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.
// CAC bank rejects amounts below 10 (DJF bounds 10100,000), so its dev
// 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 =
snapshot.status === ProviderPaymentStatus.SUCCEEDED;

View File

@@ -28,6 +28,11 @@ import {
NormalizedFaydaUserInfo,
VerifaydaPurpose,
} 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 {
purpose: VerifaydaPurpose;
@@ -143,6 +148,26 @@ export class VerifaydaService {
async completeVerification(
query: VerifaydaCallbackDto,
): 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) {
this.logger.warn(`Fayda callback returned error: ${query.error}`);
if (query.state) {

View File

@@ -25,6 +25,10 @@ import {
type IdentitySubject,
type IdentityVerificationState,
} 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 {
subject: IdentitySubject;
@@ -80,6 +84,23 @@ export default function FaydaVerifyPanel({
setError(null);
setLoading(true);
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();
// Record who is being verified and where to come back to before the tab
// leaves — /fayda/callback has no other way to know either.

View 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 ?? "");
}

View File

@@ -23,6 +23,8 @@ interface ImportMetaEnv {
readonly VITE_POSTHOG_KEY?: string;
/** Self-hosted PostHog instance URL. */
readonly VITE_POSTHOG_HOST?: string;
/** "dev" | "staging" — enables the OTP/payment/Fayda bypass. Unset in prod. */
readonly VITE_ENV?: string;
}
interface ImportMeta {