Skip to content

✨ server: add kyc link allowlist for bridge onboarding#950

Draft
mainqueg wants to merge 1 commit intomainfrom
link
Draft

✨ server: add kyc link allowlist for bridge onboarding#950
mainqueg wants to merge 1 commit intomainfrom
link

Conversation

@mainqueg
Copy link
Copy Markdown
Member

@mainqueg mainqueg commented Apr 10, 2026

Summary by CodeRabbit

  • New Features

    • Added KYC link allowlist for bridge onboarding.
  • Tests

    • Added comprehensive test coverage for KYC link resolution across multiple customer scenarios, states, and requirement conditions.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 10, 2026

🦋 Changeset detected

Latest commit: 4136984

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@exactly/server Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 10, 2026

Walkthrough

A KYC link allowlist feature is introduced for Bridge onboarding, adding schema support for a kycLink field in the provider response, implementing conditional KYC link generation logic based on customer status and endorsement requirements, and providing comprehensive test coverage for various onboarding scenarios.

Changes

Cohort / File(s) Summary
Release Configuration
.changeset/bright-owls-glow.md
Marks a patch release for @exactly/server with a release note documenting the KYC link allowlist addition for bridge onboarding.
API Schema
server/api/ramp.ts
Extends the ProviderInfo type by adding an optional kycLink: optional(string()) field to the bridge provider object.
Bridge Implementation
server/utils/ramps/bridge.ts
Introduces getKycLink() helper for building and fetching KYC URLs, adds maybeKycLink() conditional logic to determine when to fetch a KYC link based on customer status and endorsement requirements, and implements containsRequirement() for recursive requirement matching. Updates getProvider() to populate kycLink for onboarding users.
Bridge Tests
server/test/utils/bridge.test.ts
Adds 26+ new test cases covering KYC link resolution for ONBOARDING status across various conditions: missing/issue matching against allowlist, blocklist precedence, region unavailability suppression, and nested requirement structures. Updates one existing ACTIVE flow test expectation.

Sequence Diagram

sequenceDiagram
    participant Client
    participant GetProvider as getProvider()
    participant MaybeKycLink as maybeKycLink()
    participant BridgeAPI as Bridge API
    participant ErrorHandler as Sentry/Error Handler

    Client->>GetProvider: Request provider info (customerId, redirectURL)
    GetProvider->>GetProvider: Check onboarding status
    alt User in ONBOARDING
        GetProvider->>MaybeKycLink: Check if KYC link needed
        alt Conditions match (missing/issues in allowlist)
            MaybeKycLink->>BridgeAPI: getKycLink(customerId, redirectUri, endorsement)
            alt Success
                BridgeAPI-->>MaybeKycLink: KYC URL
                MaybeKycLink-->>GetProvider: kycLink populated
            else Failure
                BridgeAPI-->>MaybeKycLink: Error
                MaybeKycLink->>ErrorHandler: captureException
                MaybeKycLink-->>GetProvider: kycLink undefined
            end
        else Conditions don't match or blocked
            MaybeKycLink-->>GetProvider: kycLink undefined
        end
        GetProvider-->>Client: Provider info with kycLink
    else Other status
        GetProvider-->>Client: Provider info without kycLink
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • dieguezguille
  • franm91
  • nfmelendez
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding a KYC link allowlist feature for bridge onboarding. It directly relates to the core modifications across all files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch link

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a KYC link allowlist for bridge onboarding, allowing the system to conditionally provide a KYC URL based on specific customer requirements and endorsement statuses. Key changes include the addition of a getKycLink utility, logic to evaluate complex requirement structures (using all_of and any_of), and extensive test coverage for various onboarding scenarios. A technical issue was identified in the getKycLink function where manual URI encoding of the redirect URI leads to double-encoding, as URLSearchParams handles this automatically.

export function getKycLink(customerId: string, redirectUri?: string, endorsement?: (typeof Endorsements)[number]) {
const params = new URLSearchParams();
if (endorsement) params.set("endorsement", endorsement);
if (redirectUri) params.set("redirect_uri", encodeURIComponent(redirectUri));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

URLSearchParams.set automatically handles URL encoding for both keys and values. Manually calling encodeURIComponent on redirectUri here will result in double-encoding. Additionally, encoding should be centralized within the function that constructs the request rather than at each call site to prevent omissions and ensure consistency.

Suggested change
if (redirectUri) params.set("redirect_uri", encodeURIComponent(redirectUri));
if (redirectUri) params.set("redirect_uri", redirectUri);
References
  1. When a value is used to construct a request URL, encode it within the function that makes the request rather than at each call site to centralize the security logic and prevent future omissions.

@sentry
Copy link
Copy Markdown

sentry bot commented Apr 10, 2026

✅ All tests passed.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 33b527bb-897b-46d0-bf9d-c2084782b01d

📥 Commits

Reviewing files that changed from the base of the PR and between a967f8e and 4136984.

📒 Files selected for processing (4)
  • .changeset/bright-owls-glow.md
  • server/api/ramp.ts
  • server/test/utils/bridge.test.ts
  • server/utils/ramps/bridge.ts

Comment on lines +546 to +571
it("returns ONBOARDING without kycLink when nested missing has no matching targets", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
fetchResponse({
...activeCustomer,
status: "incomplete",
endorsements: [
{
name: "base",
status: "approved",
requirements: {
complete: [],
pending: [],
missing: {
all_of: ["address_of_residence", { any_of: ["first_name", "source_of_funds_questionnaire"] }],
},
issues: [],
},
},
],
}),
);

const result = await bridge.getProvider({ credentialId: "cred-1", customerId: "cust-1" });

expect(result.status).toBe("ONBOARDING");
expect(result.kycLink).toBeUndefined();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This negative case still matches the allowlist.

source_of_funds_questionnaire in this fixture is one of the allowed missing requirements, so containsRequirement() in server/utils/ramps/bridge.ts will try to fetch a KYC link here. The test only passes because the default fetch mock turns that extra request into kycLink: undefined, which makes this a false positive.

🧪 Suggested fix
                 missing: {
-                  all_of: ["address_of_residence", { any_of: ["first_name", "source_of_funds_questionnaire"] }],
+                  all_of: ["address_of_residence", { any_of: ["first_name", "last_name"] }],
                 },
               },
             ],
@@
         const result = await bridge.getProvider({ credentialId: "cred-1", customerId: "cust-1" });

         expect(result.status).toBe("ONBOARDING");
         expect(result.kycLink).toBeUndefined();
+        expect(globalThis.fetch).toHaveBeenCalledTimes(1);

Please apply the same “only one fetch call” assertion to the other negative KYC-link cases above as well.

Comment on lines +160 to +167
export function getKycLink(customerId: string, redirectUri?: string, endorsement?: (typeof Endorsements)[number]) {
const params = new URLSearchParams();
if (endorsement) params.set("endorsement", endorsement);
if (redirectUri) params.set("redirect_uri", encodeURIComponent(redirectUri));
return request(
object({ url: string() }),
`/customers/${customerId}/kyc_link${String(params) ? `?${String(params)}` : ""}`,
).then((result) => result.url);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, confirm the code at lines 160-167
head -n 170 server/utils/ramps/bridge.ts | tail -n 20

Repository: exactly/exa

Length of output: 776


🏁 Script executed:

# Search for usages of getKycLink to see how it's called
rg "getKycLink" --type ts --type tsx -B 2 -A 2

Repository: exactly/exa

Length of output: 82


🏁 Script executed:

# Look for test files related to this module
fd -e test.ts -e spec.ts | xargs grep -l "getKycLink\|bridge" 2>/dev/null | head -5

Repository: exactly/exa

Length of output: 211


🏁 Script executed:

# Check the structure of URLSearchParams usage in the codebase
rg "URLSearchParams" --type ts --type tsx -B 1 -A 1 | head -50

Repository: exactly/exa

Length of output: 82


🏁 Script executed:

# Check if there are tests for getKycLink
cat server/test/utils/bridge.test.ts

Repository: exactly/exa

Length of output: 50367


🏁 Script executed:

# Check how getKycLink is used/called in the codebase
rg "getKycLink" --type ts -B 3 -A 3

Repository: exactly/exa

Length of output: 1520


🏁 Script executed:

# Also check if there's any documentation or comments about URL encoding in bridge.ts
head -n 200 server/utils/ramps/bridge.ts | tail -n 100

Repository: exactly/exa

Length of output: 4069


🏁 Script executed:

# Check how redirectURL flows through getProvider and into getKycLink
rg "redirectURL\|redirectUri" server/utils/ramps/bridge.ts -B 2 -A 2

Repository: exactly/exa

Length of output: 37


🏁 Script executed:

# Look for any integration tests or actual usage of redirectURL
rg "redirectURL" server/test -B 2 -A 2 | head -50

Repository: exactly/exa

Length of output: 458


🏁 Script executed:

# Let's search for the specific test that uses redirectURL to see what it validates
rg "appends redirect URL with provider param" server/test/utils/bridge.test.ts -A 10

Repository: exactly/exa

Length of output: 604


🏁 Script executed:

# Check how getProvider passes redirectURL to getKycLink
rg "getKycLink\|redirectURL" server/utils/ramps/bridge.ts -B 3 -A 3

Repository: exactly/exa

Length of output: 37


🏁 Script executed:

# Let's view the full getProvider function to see how redirectURL flows to getKycLink
sed -n '214,280p' server/utils/ramps/bridge.ts

Repository: exactly/exa

Length of output: 2650


🏁 Script executed:

# Search for where getKycLink is called with redirectUri
rg "getKycLink" server/utils/ramps/bridge.ts -A 1 -B 1

Repository: exactly/exa

Length of output: 360


🏁 Script executed:

# Let's verify URLSearchParams encoding behavior with a test
node <<'EOF'
const params = new URLSearchParams();
const redirectUri = "https://app.example.com/callback";

// First, what happens with pre-encoding
params.set("redirect_uri", encodeURIComponent(redirectUri));
console.log("With pre-encoding:", String(params));
console.log("Decoded value:", decodeURIComponent(params.get("redirect_uri")));

// What should happen without pre-encoding
const params2 = new URLSearchParams();
params2.set("redirect_uri", redirectUri);
console.log("\nWithout pre-encoding:", String(params2));
console.log("Decoded value:", decodeURIComponent(params2.get("redirect_uri")));
EOF

Repository: exactly/exa

Length of output: 308


Remove pre-encoding of redirect_uri before passing to URLSearchParams.

Line 163 double-encodes the redirect URI. URLSearchParams.set() already percent-encodes values, so calling encodeURIComponent() first causes double-encoding: a redirect like https://app.example.com/callback becomes https%253A%252F%252Fapp.example.com%252Fcallback. Bridge receives the encoded literal string instead of the actual URL, causing the post-KYC redirect to fail.

🛠️ Proposed fix
 export function getKycLink(customerId: string, redirectUri?: string, endorsement?: (typeof Endorsements)[number]) {
   const params = new URLSearchParams();
   if (endorsement) params.set("endorsement", endorsement);
-  if (redirectUri) params.set("redirect_uri", encodeURIComponent(redirectUri));
+  if (redirectUri) params.set("redirect_uri", redirectUri);
   return request(
     object({ url: string() }),
     `/customers/${customerId}/kyc_link${String(params) ? `?${String(params)}` : ""}`,
   ).then((result) => result.url);
 }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant