feat(checkout): add identityVerificationHandling so a merchant can own the verification step - #2005
feat(checkout): add identityVerificationHandling so a merchant can own the verification step#2005AngelPaella wants to merge 7 commits into
Conversation
…n step The SDK half of the escape hatch. Paella-Labs/crossbit-main#28380 carries the checkout page half and has to deploy first, otherwise the flag is stripped and both checkout and the merchant render a widget for the same inquiry. `kycHandling="external"` rides to the checkout page as a query param through the existing prop serializer, and stops checkout from rendering the step. `getIdentityVerificationCredentials(order)` reads `payment.preparation.kyc` off an order from useCrossmintCheckout(), so a merchant gets the credentials for CrossmintIdentityVerification without casting. The Order type in this package mirrors the backend schema by hand and does not model the kyc preparation variant, so the reader keeps that one cast inside the SDK rather than in every integration. Typing the mirror properly is filed separately.
🦋 Changeset detectedLatest commit: 63bc9fa The changes in this PR will be included in the next version bump. This PR includes changesets to release 18 packages
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 |
Prompt To Fix All With AI### Issue 1
packages/client/base/src/services/identity-verification/get-identity-verification-credentials.test.ts:12-13
**`environmentId` in test fixture but absent from `IdentityVerificationCredentials` type**
The test fixture includes `environmentId: null` as part of the credentials, but `IdentityVerificationCredentials` is typed as `{ provider: "persona"; inquiryId: string; sessionToken?: string }` — no `environmentId` field. Because the function casts `preparation as { kyc?: IdentityVerificationCredentials }`, the returned object's runtime shape (with `environmentId`) silently diverges from its TypeScript type. If `CrossmintIdentityVerification` (or a merchant using the SDK's own Persona integration) needs to read `credentials.environmentId`, it would have to use an unchecked cast; otherwise, the component would receive the field at runtime but TypeScript would reject access to it statically.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Reviews (1): Last reviewed commit: "feat(checkout): add kycHandling so a mer..." | Re-trigger Greptile |
Review follow-ups. The flag was on the props type react-native shares with web, so an RN merchant could set `kycHandling="external"`, typecheck, reach the page, and have checkout suppress the step with nothing able to replace it: CrossmintIdentityVerification renders a DOM iframe and RN's barrel exports neither it nor the credentials reader. The order would sit at requires-kyc behind a collapsed WebView. It now lives on CrossmintEmbeddedCheckoutV3WebProps, which only the react-ui component takes, so RN rejects it at compile time until RN has something to mount. `getIdentityVerificationCredentials` takes `Order | undefined`, because useCrossmintCheckout() returns `order?: Order` and the documented call site did not typecheck without a non-null assertion, which would have discarded the defensiveness the reader exists to provide. The changeset now states the deploy-order requirement, since the changelog is where a merchant reads it.
|
Reviews (2): Last reviewed commit: "refactor(checkout): keep kycHandling off..." | Re-trigger Greptile |
…external only Renames `kycHandling` to `identityVerificationHandling` and drops the non-external value from the union. The name now matches the vocabulary the rest of the identity surface uses (`CrossmintIdentityVerification`, `getIdentityVerificationCredentials`) rather than KYC, which is the provider's term for one step of it. `"checkout"` is gone rather than renamed to `"embedded"`. Nothing branches on it on the checkout page: the only read is `=== "external"`. Widening an optional union later is backward compatible and free, so naming a second value now buys nothing and commits us to a name before the third mode's semantics are known. "embedded" would also have been the wrong name, since the merchant's replacement is an iframe too, so it does not distinguish the two paths. The page-side rename is Paella-Labs/crossbit-main#28380, which still has to reach production before this ships.
|
Reviews (3): Last reviewed commit: "refactor(checkout): rename the prop to i..." | Re-trigger Greptile |
WindowTransport subscribes to the global message event and accepted anything from a matching origin. One Crossmint iframe per page kept that harmless: no other frame could send from that origin. Two iframes on one page and each client receives the other's events. `identityVerificationHandling="external"` is the first configuration that puts two there, the checkout iframe and the CrossmintIdentityVerification iframe the merchant mounts. Both serve from the same origin, and `ui:height.changed` is the one event name their maps share. Checkout suppressed its step and reported height 0, the verification iframe reported 660, and the checkout iframe took it, leaving 660px of empty space above the merchant's widget. The failure flips with message order: checkout's 0 arriving last sets the merchant's Persona form to 0 and the buyer cannot verify at all. Sending was already addressed to `otherWindow`; only the receive path threw that away. SignersWindowTransport does this check today. IFrameWindow, ChildWindow, PopupWindow and NewTabWindow all construct the transport with their real peer, so it holds for every caller. A message whose sending window has closed carries a null source and now drops. Verified end to end against ~/dev/kyc-e2e-harness §F with a real Persona sandbox form: checkout iframe 660px and empty before, 0px after, merchant's form at its own 660 with kyc:ready firing.
182e218 to
ad57d20
Compare
|
Reviews (4): Last reviewed commit: "fix(window): match the peer window on re..." | Re-trigger Greptile |
environmentId arrives on the wire, is hardcoded to null by the backend, and PAY-11803 removes it in the next API version. Nothing in the SDK reads it, so putting it on IdentityVerificationCredentials would enshrine a field that is always null and already scheduled for deletion. The fixture keeps it to pin that the reader hands back the order's object unreshaped.
|
Reviews (5): Last reviewed commit: "test(identity): say why the fixture carr..." | Re-trigger Greptile |
vmc-crossmint
left a comment
There was a problem hiding this comment.
Approved for me, but i prefer @maxwellfortney to review too as he has context on sdk details
| // Web only: taking over the verification step needs CrossmintIdentityVerification, which renders an | ||
| // iframe. React Native has no equivalent yet, so the flag stays off the shared props. | ||
| export type CrossmintEmbeddedCheckoutV3WebProps = CrossmintEmbeddedCheckoutV3Props & { | ||
| /** `"external"`: you mount `CrossmintIdentityVerification` from | ||
| * `getIdentityVerificationCredentials(order)`, or the buyer cannot finish. */ | ||
| identityVerificationHandling?: "external"; | ||
| }; |
There was a problem hiding this comment.
What does it mean, we our sdk cannot work in react native?
There was a problem hiding this comment.
RN checkout keeps working, and this PR leaves it alone. Verification still runs inside checkout's WebView there.
Only the opt-out is web. identityVerificationHandling="external" tells the checkout page to skip its verification step because the merchant renders it instead. The one component for that job, CrossmintIdentityVerification, is a DOM iframe, and RN has no counterpart to it yet.
Put the flag on the shared props and an RN merchant's code compiles clean all the way to the page. Checkout suppresses the step and nothing replaces it. The order sits at requires-kyc behind a collapsed WebView, no message shown. Keeping the flag off the shared type makes that a compile error instead. RN gets it back when there is a WebView verification component to mount.
|
Reviews (6): Last reviewed commit: "docs: clarify why identityVerificationHa..." | Re-trigger Greptile |
|
Reviews (7): Last reviewed commit: "docs: say the RN gap is an unbuilt compo..." | Re-trigger Greptile |
The SDK half of the merchant escape hatch for KYC, §4.5 of the Identity SDK PRD. ENG4-367.
The checkout page half is Paella-Labs/crossbit-main#28380, which has to reach production before this ships. Unknown query params are stripped, so a newer SDK against an older page loses the flag and both checkout and the merchant render a widget for the same inquiry.
What this adds
identityVerificationHandling?: "external"onCrossmintEmbeddedCheckoutV3WebProps, which only the react-ui component takes. It rides to the checkout page as a query param throughappendObjectToQueryParams, which appends strings bare, so the page receivesidentityVerificationHandling=externaland its zod enum accepts it. It intersects the shared props type, so both the existing-order and new-order shapes keep it.Web only, deliberately. It started on the props type react-native shares with web, which meant an RN merchant could set the flag, typecheck, reach the page, and have checkout suppress the step with nothing able to replace it:
CrossmintIdentityVerificationrenders a DOM iframe, and RN's barrel exports neither it nor the credentials reader. The order would sit atrequires-kycbehind a collapsed WebView with no message. RN now rejects the flag at compile time, and gets it back when there is an RN component to mount.getIdentityVerificationCredentials(order), takingOrder | undefinedand returningIdentityVerificationCredentials | undefined. It has to accept undefined:useCrossmintCheckout()returnsorder?: Order, so a narrower parameter would have forced every merchant intoorder!, discarding exactly the defensiveness the reader exists to provide. A merchant taking the step over reads the order from the hook and passes the result toCrossmintIdentityVerification:Re-exported from
@crossmint/client-sdk-react-uiso merchants do not have to depend onclient-sdk-basedirectly.Why one value and not two
The prop started as
kycHandling?: "checkout" | "external". Two things changed.The name is now
identityVerificationHandling, matching the vocabulary the rest of this surface already uses (CrossmintIdentityVerification,getIdentityVerificationCredentials). KYC is the provider's term for one step of identity verification, and it was the odd one out. This diverges from §4.5 of the PRD, which writeskycHandling.The union is a single value. Nothing branches on the non-external value on the checkout page, where the only read is
=== "external". Widening an optional union later is backward compatible and free, so a second value buys nothing today and commits us to a name before the third mode's real semantics are known. If the in-house UI never ships, we never paid for it."embedded"was the leading candidate and is actively misleading besides, since the merchant's replacement is an iframe too: ownership is the axis, not where it renders.Consequence for the page:
identityVerificationHandling="embedded"is now rejected rather than ignored, so it hits checkout's validation error screen like any unknown value. That is the existing loud-rejection behaviour, and it means a second mode has to reach production on the page before it can ship here.A transport fix rides along
Running this flow in a browser turned up a bug in
WindowTransport, and the fix ships here becauseidentityVerificationHandling="external"is what exposes it.addMessageListenersubscribes to the globalmessageevent and filtered onevent.originalone. One Crossmint iframe per page kept that harmless, since no other frame could send from that origin. This flag puts two on the page: the checkout iframe, and theCrossmintIdentityVerificationiframe the merchant mounts. Both serve from the same origin, andui:height.changedis the one event name their maps share, so each client received the other's heights.The result contradicted what this PR and #28380 both claim. Checkout suppressed its step and reported
height: 0, the verification iframe reported 660, and the checkout iframe took that, so the merchant got 660px of blank space where the box should have collapsed. The failure flips with message order: checkout's 0 arriving last sets the merchant's Persona form to 0, and the buyer cannot verify at all. An early harness run recorded that version.Captured in the parent with a listener that tags each message with its sending frame:
WindowTransportnow comparesevent.sourceto the peer window it already holds for sending.SignersWindowTransportdoes this today.IFrameWindow,ChildWindow,PopupWindowandNewTabWindowall construct the transport with their real peer, so the check holds for every caller. A message whose sending window has closed carries a null source and drops.Worth a reviewer's attention: this narrows a trust boundary that has been loose since the transport was written, and it affects every product using these iframes, not only checkout. A caller that relied on receiving from a window other than its peer would stop working. I found none.
Why a reader instead of typing the Order
packages/client/base/src/lib/hosted-checkout/Order.tssays at the top that it is kept in sync with@crossmint/products-payments-headless-checkoutby hand, and it has drifted: the backend models the kyc preparation variant, this mirror has nokyckey anywhere inpreparation. Editing a union inside 5000 lines of declared types is a bigger change than this feature and is not KYC-specific, so the reader holds the one cast inside the SDK instead of leaving it in every integration. Typing the mirror properly is filed separately, and it will simplify the reader's body without touching its signature.The reader also optional-chains
order.payment, because an order reaches the merchant over postMessage whereorderis typedz.any()on both ends. A shape the type forbids should return undefined rather than throw in the merchant's app.Test plan
get-identity-verification-credentials.test.ts, new, 5 cases: returns the credentials, and returns undefined for a non-verification preparation, for no preparation, for an order with no payment at all, and for no order yet. All pass.window-transport.test.ts, new, 4 cases: a same-origin non-peer frame gets dropped, a null source gets dropped, a foreign origin still gets dropped, andremoveMessageListenerunsubscribes. Take the source check back out and the first two fail. This package had no tests before, so it also gains atest:vitestscript and avitest.config.tscopied fromclient-sdk-base.pnpm turbo buildonclient-sdk-base,client-sdk-react-uiandclient-sdk-react-native-uipasses, which is what typechecks this repo. In the built output I checked the three things that matter: base's declarations exportgetIdentityVerificationCredentialsandCrossmintEmbeddedCheckoutV3WebPropscarryingidentityVerificationHandling?: "external", react-ui's two embed components declare the web props type, and react-native'sdist/index.d.tscontains no mention of the flag.pnpm lintis clean on the touched files. The three warnings biome reports on the props file are pre-existingRecord<string, any>line items.Run in a browser, against
~/dev/kyc-e2e-harness§F: the quickstart on :3006 withidentityVerificationHandling="external", crossbit-main #28380 on :3000, driven by Playwright. Checkout's iframe measures 0 and its body is empty, the merchant's slot mounts a real Persona sandbox form at 660 as a sibling rather than a nested frame,kyc:readyfires, checkout pollsGET /orderson its 3s cadence, and the verification iframe'ssrcstays byte-identical across those ticks so the fresh-per-poll credentials object does not remount Persona.getUrl()on the built dist emits?identityVerificationHandling=external, omits the param when the prop is unset, and never emits the oldkycHandlingname. The receiving end has a schema test in #28380 that parses a bareidentityVerificationHandling=external, plus a browser check that the page rejects any other value with its validation screen.Not in this PR: docs for the takeover flow, and react-native support, which needs an identity verification component that is not an iframe before the flag can mean anything there.
The changeset states the deploy-order requirement too, since the changelog is where a merchant reads it rather than this description.