Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/main-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ jobs:
# Skip if the push is a release commit (to avoid infinite loop)
if: "!startsWith(github.event.head_commit.message, 'chore: release')"
uses: ./.github/workflows/ci.yml
name: Lint, Typecheck, Test, Build

release-and-publish:
name: Release & Publish
Expand Down
151 changes: 104 additions & 47 deletions IMPLEMENTATION_PLAN.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions example/ios/Podfile.lock
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
PODS:
- BoltReactNativeSdk (0.1.4):
- BoltReactNativeSdk (0.2.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
Expand Down Expand Up @@ -2063,7 +2063,7 @@ EXTERNAL SOURCES:
:path: "../../node_modules/react-native/ReactCommon/yoga"

SPEC CHECKSUMS:
BoltReactNativeSdk: fa7f6fabeeedd1958983a79edc208d38f9033822
BoltReactNativeSdk: c28f7ee61812546d542d25990defa3aaee46974c
FBLazyVector: c12d2108050e27952983d565a232f6f7b1ad5e69
hermes-engine: f4f579ea06f83a03800993f3f445a5025f78f893
RCTDeprecation: 3280799c14232a56e5a44f92981a8ee33bc69fd9
Expand Down
9 changes: 5 additions & 4 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ import type {
// Initialize Bolt with your publishable key
const bolt = new Bolt({
publishableKey:
'yayzpqS9Y7Qb.MBLn0CaZCM7I.aa226a2b80c3aac19300f82dc6be8e92c91b8df1d527311a79e8b190af1f6b2b',
// 3ds:
'tFb8YsxCSGSb.fS3kkcd6a-tl.713c7e045966cb916ccf42ba5becfcebf1a3a8042584bbac5cc74f8a8feebd2b',
//'yayzpqS9Y7Qb.MBLn0CaZCM7I.aa226a2b80c3aac19300f82dc6be8e92c91b8df1d527311a79e8b190af1f6b2b',
environment: 'staging',
});

Expand All @@ -43,9 +45,8 @@ bolt.configureOnPageStyles({

const AddCardScreen = () => {
const cc = CreditCard.useController({
styles: {
'--bolt-input-backgroundColor': '#fafafa',
},
styles: { '--bolt-input-backgroundColor': '#fafafa' },
showBillingZIPField: true,
});
const threeDSecure = useThreeDSecure();
const [tokenResult, setTokenResult] = useState<TokenResult | null>(null);
Expand Down
15 changes: 15 additions & 0 deletions src/__tests__/useCreditCardController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,21 @@ describe('CreditCard tokenization message flow', () => {
expect(payload.type).toBe('GetToken');
});

it('should send SetConfig with showBillingZIPField when configured', () => {
dispatcher.sendMessage(
JSON.stringify({
type: 'SetConfig',
config: { showBillingZIPField: true },
})
);

expect(sentMessages).toHaveLength(1);
const sent = JSON.parse(sentMessages[0]!);
const payload = JSON.parse(sent.data);
expect(payload.type).toBe('SetConfig');
expect(payload.config.showBillingZIPField).toBe(true);
});

it('should receive successful GetTokenReply with card data', (done) => {
const unsub = dispatcher.onMessage((data) => {
const msg = parseBoltMessage(data);
Expand Down
98 changes: 53 additions & 45 deletions src/__tests__/useThreeDSecure.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,7 @@ describe('useThreeDSecure - fetchReferenceID', () => {
dispatcher.sendMessage(
JSON.stringify({
type: 'FetchReferenceID',
token: 'tok_123',
bin: '411111',
last4: '1111',
creditCard: { token: 'tok_123', bin: '411111', last4: '1111' },
})
);
unsub();
Expand All @@ -80,44 +78,47 @@ describe('useThreeDSecure - fetchReferenceID', () => {
expect(sent.data).toBeDefined();
const payload = JSON.parse(sent.data);
expect(payload.type).toBe('FetchReferenceID');
expect(payload.token).toBe('tok_123');
expect(payload.bin).toBe('411111');
expect(payload.last4).toBe('1111');
expect(payload.creditCard.token).toBe('tok_123');
expect(payload.creditCard.bin).toBe('411111');
expect(payload.creditCard.last4).toBe('1111');
});

it('should send FetchReferenceID with credit card id fields', () => {
dispatcher.sendMessage(
JSON.stringify({
type: 'FetchReferenceID',
id: 'cc_abc123',
expiration: '2028-12',
creditCard: { id: 'cc_abc123', expiration: '2028-12' },
})
);

expect(sentMessages.length).toBeGreaterThan(0);
const sent = JSON.parse(sentMessages[0]!);
const payload = JSON.parse(sent.data);
expect(payload.type).toBe('FetchReferenceID');
expect(payload.id).toBe('cc_abc123');
expect(payload.expiration).toBe('2028-12');
expect(payload.creditCard.id).toBe('cc_abc123');
expect(payload.creditCard.expiration).toBe('2028-12');
});

it('should resolve with referenceID on VerificationIDResult', (done) => {
let messageHandler: ((data: unknown) => void) | null = () => {
// mock
};

dispatcher.onMessage((data) => {
if (messageHandler) messageHandler(data);
});

// Simulate the iframe sending a VerificationIDResult
// Simulate the iframe sending a VerificationIDResult with errorCode: -1
// (Cardinal convention for "no error")
const responseMessage = JSON.stringify({
type: 'VerificationIDResult',
referenceID: 'ref_3ds_abc123',
success: 'ref_3ds_abc123',
errorCode: -1,
});

const unsub = dispatcher.onMessage((data) => {
const parsed =
typeof data === 'string' ? JSON.parse(data as string) : data;
if (parsed.type === 'VerificationIDResult') {
expect(parsed.success).toBe('ref_3ds_abc123');
expect(parsed.errorCode).toBe(-1);
unsub();
done();
}
});

// Simulate receiving the message from the WebView
dispatcher.handleMessage({
nativeEvent: {
data: JSON.stringify({
Expand All @@ -128,26 +129,33 @@ describe('useThreeDSecure - fetchReferenceID', () => {
}),
},
});
});

// Verify the listener receives the message
it('should propagate Result error when DDC JWT call fails', (done) => {
// When the DDC JWT API call fails, storm sends { type: "Result", success: false, errorCode: 1010 }
// instead of a VerificationIDResult. fetchReferenceID must handle this.
const unsub = dispatcher.onMessage((data) => {
const parsed =
typeof data === 'string' ? JSON.parse(data as string) : data;
if (parsed.type === 'VerificationIDResult') {
expect(parsed.referenceID).toBe('ref_3ds_abc123');
if (parsed.type === 'Result') {
expect(parsed.success).toBe(false);
expect(parsed.errorCode).toBe(1010);
unsub();
done();
}
});

// Re-emit the message so the new listener catches it
dispatcher.handleMessage({
nativeEvent: {
data: JSON.stringify({
__boltBridge: true,
direction: 'outbound',
type: 'postMessage',
data: responseMessage,
data: JSON.stringify({
type: 'Result',
success: false,
errorCode: 1010,
}),
}),
},
});
Expand Down Expand Up @@ -217,9 +225,11 @@ describe('useThreeDSecure - challengeWithConfig', () => {
JSON.stringify({
type: 'TriggerAuthWithConfig',
orderToken: 'order_123',
referenceID: 'ref_3ds_abc',
jwtPayload: 'jwt.payload.here',
stepUpUrl: 'https://example.com/stepup',
config: {
referenceID: 'ref_3ds_abc',
jwtPayload: 'jwt.payload.here',
stepUpUrl: 'https://example.com/stepup',
},
})
);

Expand All @@ -228,9 +238,9 @@ describe('useThreeDSecure - challengeWithConfig', () => {
const payload = JSON.parse(sent.data);
expect(payload.type).toBe('TriggerAuthWithConfig');
expect(payload.orderToken).toBe('order_123');
expect(payload.referenceID).toBe('ref_3ds_abc');
expect(payload.jwtPayload).toBe('jwt.payload.here');
expect(payload.stepUpUrl).toBe('https://example.com/stepup');
expect(payload.config.referenceID).toBe('ref_3ds_abc');
expect(payload.config.jwtPayload).toBe('jwt.payload.here');
expect(payload.config.stepUpUrl).toBe('https://example.com/stepup');
});

it('should receive success Result from 3DS challenge', (done) => {
Expand Down Expand Up @@ -366,7 +376,7 @@ describe('useThreeDSecure - 3DS bootstrap flow integration', () => {
receivedMessages.push(parsed as Record<string, unknown>);

if (parsed.type === 'VerificationIDResult') {
expect(parsed.referenceID).toBe('ref_3ds_bootstrap_123');
expect(parsed.success).toBe('ref_3ds_bootstrap_123');
expect(receivedMessages).toHaveLength(1);
unsub();
done();
Expand All @@ -377,9 +387,7 @@ describe('useThreeDSecure - 3DS bootstrap flow integration', () => {
dispatcher.sendMessage(
JSON.stringify({
type: 'FetchReferenceID',
token: 'tok_card_abc',
bin: '411111',
last4: '1111',
creditCard: { token: 'tok_card_abc', bin: '411111', last4: '1111' },
})
);

Expand All @@ -392,7 +400,7 @@ describe('useThreeDSecure - 3DS bootstrap flow integration', () => {
type: 'postMessage',
data: JSON.stringify({
type: 'VerificationIDResult',
referenceID: 'ref_3ds_bootstrap_123',
success: 'ref_3ds_bootstrap_123',
}),
}),
},
Expand All @@ -413,9 +421,11 @@ describe('useThreeDSecure - 3DS bootstrap flow integration', () => {
JSON.stringify({
type: 'TriggerAuthWithConfig',
orderToken: 'order_bootstrap_1',
referenceID: parsed.referenceID,
jwtPayload: 'jwt.test.payload',
stepUpUrl: 'https://test.cardinal.com/stepup',
config: {
referenceID: parsed.success,
jwtPayload: 'jwt.test.payload',
stepUpUrl: 'https://test.cardinal.com/stepup',
},
})
);

Expand Down Expand Up @@ -443,7 +453,7 @@ describe('useThreeDSecure - 3DS bootstrap flow integration', () => {
const msg2 = JSON.parse(JSON.parse(sentMessages[1]!).data);
expect(msg1.type).toBe('FetchReferenceID');
expect(msg2.type).toBe('TriggerAuthWithConfig');
expect(msg2.referenceID).toBe('ref_3ds_full_flow');
expect(msg2.config.referenceID).toBe('ref_3ds_full_flow');
unsub();
done();
}
Expand All @@ -453,9 +463,7 @@ describe('useThreeDSecure - 3DS bootstrap flow integration', () => {
dispatcher.sendMessage(
JSON.stringify({
type: 'FetchReferenceID',
token: 'tok_full_flow',
bin: '411111',
last4: '1111',
creditCard: { token: 'tok_full_flow', bin: '411111', last4: '1111' },
})
);

Expand All @@ -467,7 +475,7 @@ describe('useThreeDSecure - 3DS bootstrap flow integration', () => {
type: 'postMessage',
data: JSON.stringify({
type: 'VerificationIDResult',
referenceID: 'ref_3ds_full_flow',
success: 'ref_3ds_full_flow',
}),
}),
},
Expand Down
5 changes: 4 additions & 1 deletion src/bridge/BoltPaymentWebView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,10 @@ export const BoltPaymentWebView = forwardRef<

const handleShouldStartLoad = useCallback(
(request: ShouldStartLoadRequest): boolean => {
// Only allow navigation within the Bolt domain
// Allow all sub-frame navigations (e.g., Cardinal Commerce DDC form
// submission, 3DS step-up challenge iframe)
if (request.isTopFrame === false) return true;
// Only restrict top-level navigation to the Bolt domain
return (
request.url.startsWith(bolt.baseUrl) || request.url === 'about:blank'
);
Expand Down
24 changes: 24 additions & 0 deletions src/bridge/injectedBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,30 @@ export const INJECTED_BRIDGE_JS = `
}
}
// ── Forward real DOM message events from sub-iframes ────
// Sub-iframes (e.g., Cardinal Commerce DDC) use real postMessage to
// communicate with the top frame. Since we intercepted addEventListener,
// those events won't reach the captured listeners unless we forward them.
originalAddEventListener('message', function(event) {
// Skip bridge envelopes — those are handled by __boltBridgeReceive
if (event.data && typeof event.data === 'object' && event.data.__boltBridge) return;
if (typeof event.data === 'string') {
try {
var parsed = JSON.parse(event.data);
if (parsed && parsed.__boltBridge) return;
} catch(e) {}
}
// Forward the real event to all captured message listeners
for (var i = 0; i < messageListeners.length; i++) {
try {
messageListeners[i](event);
} catch (err) {
console.error('[BoltBridge] Error in message listener (forwarded):', err);
}
}
});
// ── Receive messages from React Native ───────────────────
// React Native sends messages by evaluating JS on the WebView.
Expand Down
10 changes: 10 additions & 0 deletions src/payments/useCreditCardController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ export interface CreditCardController {

export interface CreditCardControllerOptions {
styles?: Styles;
/**
* Show the billing ZIP / postal code field inside the PCI-sandboxed WebView.
* When true, Storm's iframe renders its built-in CardPostalField and the
* collected value is returned as `postal_code` in the TokenResult.
* Defaults to false.
*/
showBillingZIPField?: boolean;
}

/**
Expand Down Expand Up @@ -84,6 +91,9 @@ export const useCreditCardController = (
? { version: 3, ...onPageStyles }
: undefined;
})(),
...(optionsRef.current?.showBillingZIPField
? { showBillingZIPField: true }
: {}),
},
})
);
Expand Down
Loading
Loading