Skip to main content

SentinelChallenge

SentinelChallenge is the user-facing Cap.js captcha component rendered when the backend responds with a 403 { action: 'CAPTCHA_REQUIRED', userCaptchaId }. It is a React component exported from @traveloka/core, and is normally consumed indirectly — it is mounted globally by SentinelChallengeProvider, which any callAPI/callAPIClientSide request can trigger transparently.

Responsibilities

ResponsibilityDetail
Resolve challenge targetDetermines the challengeId to solve, from props or the current user session
Avoid double-mountingGuards against a second challenge instance stealing the global fetch override
Lazy-load the solverLoads the actual Cap.js widget (SentinelChallengeSolver) via next/dynamic, ssr: false

Public API

SentinelChallenge

import { SentinelChallenge } from '@traveloka/core';

A React component. In normal usage you don't render it directly — see Global wiring below.

SentinelChallengeProps

import type { SentinelChallengeProps } from '@traveloka/core';
PropTypeDescription
challengeIdstringThe captcha challenge id to solve. Falls back to userState.userCaptchaId if omitted
type'capjs'Challenge widget type. Currently only Cap.js is supported
endpoints{ challenge?, redeem? }Overrides the default challenge/redeem API endpoints
domainProductDomainOverrides the default product domain used for the challenge/redeem calls
onSuccess() => voidCalled when the captcha is solved
onError() => voidCalled when the captcha fails or errors
onLoading() => voidCalled while the widget is loading

If neither challengeId prop nor userState.userCaptchaId is available, SentinelChallenge renders null.

Global Wiring

In practice, SentinelChallenge is never rendered by application code directly. It is mounted by SentinelChallengeProvider, which itself is mounted once by SentinelManager (see sentinel.md):

SentinelManager
└─ SentinelChallengeProvider (registers window.registerSolveSentinelChallenge)
└─ SentinelChallenge (rendered only while a challenge is active)
└─ SentinelChallengeSolver (lazy-loaded Cap.js widget)
  1. Any API call (via callAPIClientSide) that receives 403 { action: 'CAPTCHA_REQUIRED', userCaptchaId } calls waitForSentinelChallenge({ userCaptchaId }) (packages/core/api/helpers/sharedAPIHelpers.ts).
  2. waitForSentinelChallenge invokes window.registerSolveSentinelChallenge(userCaptchaId, resolve), which SentinelChallengeProvider registered on mount.
  3. SentinelChallengeProvider sets its activeChallenge state, causing SentinelChallenge to render with that challengeId.
  4. Once the user solves (or fails) the captcha, SentinelChallenge's onSuccess/onError resolves the pending promise back in callAPIClientSide, which retries the original request on success.

Only one challenge can be active at a time per page; a second concurrent request for a different userCaptchaId is rejected (resolved as false) while one is in progress.

window.CAP_CUSTOM_FETCH Ownership

SentinelChallengeSolver (the lazy-loaded Cap.js widget) installs a custom fetch override on window.CAP_CUSTOM_FETCH while mounted, and removes it on unmount — but only if it still owns that reference (identity-checked), so it never clobbers a handler installed by another captcha flow (e.g. app-common/captcha/Captcha.tsx). SentinelChallenge itself checks this global on mount: if a challenge is already owned elsewhere, it renders null instead of mounting a second, conflicting solver.

Safety Timeout

To prevent a challenge that never resolves (e.g. the user abandons the page, or the widget silently fails to mount) from permanently wedging the provider, SentinelChallengeProvider starts a SENTINEL_CHALLENGE_TIMEOUT_MS (30s, challenge/constants.ts) timer when a challenge becomes active. If still unresolved when it fires, the provider logs [sen.challenge] Challenge timed out waiting for resolution and resolves the challenge as failed (false), freeing it up for the next request.

  • sentinel.md — overall Sentinel architecture and SentinelManager.
  • packages/core/sentinel/challenge/README.md — implementation-level details and ownership contract for contributors to this package.

Last updated: 2026-08-03