Kafene JS SDK: Repo X-Ray

Architecture and health dashboard, built from a live read of the repo this session, not from its docs.
repo kafene-js-sdk (Kafene SDK and WooCommerce Implementation) branch denial_message stack Next.js + TypeScript + zod + zustand generated 2026-09-07

Modules

app/

Router entry. page.tsx redirects to /checkout. checkout/page.tsx bootstraps the iframe: reads bearer token, merchant ID, and API base URL from the parent's postMessage config, prefills identity fields from WooCommerce data. Six proxy routes live under api/.

components/checkout/

The wizard itself. CheckoutContainer.tsx (740+ lines) owns step transitions, submit/invoice/poll orchestration, and result-screen mapping. One step component each for Identity, Payment, Review, Agreement.

components/ui/

Stateless form primitives: Input, Select, Checkbox, Button, PhoneInput, SSNInput, DateInput, Loader, ErrorMessage. All wire directly into react-hook-form's register/error props.

lib/api/

application.ts, invoice.ts, token.ts are the real callers of the three live proxy routes.

client.ts (ApiClient) is unused, never called
lib/validation/schemas.ts

Single source of validation: personalInfoSchema, paymentInfoSchema, agreementSchema, checkoutSchema, built with zod. Used client-side and by the (unreachable) /api/validate route.

lib/iframe/communication.ts

The postMessage protocol layer: sendToParent, listenToParent, notifyReady/StepChange/FormUpdate/Submit/Error, requestResize. Holds the origin allowlist.

lib/store/checkoutStore.ts

zustand store: form data, current step, result screen, approval amount, application ID, cart data, bearer token and its expiry.

lib/hooks/ & lib/utils/

useFormStep, useIframeCommunication, useCleanup. Utils: cart math (110% approval rule), phone/SSN formatting, a namespaced storage wrapper.

public/plugin/js/kafene.js

The merchant-side embed script, outside the Next build. Builds the modal/iframe and, notably, does check target origin when posting config into the iframe. The iframe's replies back do not (see risk 1).

Endpoints

RouteInputOutputAuth
POST /api/validateunreachable { step: number, data } 200 pass / 200 {success:false, errors} on invalid / 500 on exception none
POST /api/submitunreachable, fakes success checkoutSchema shape: personalInfo, paymentInfo, agreements 200 {success:true, orderId:'ORD-'+Date.now()}, nothing saved or processed / 400 on invalid / 500 on exception none
POST /api/applications { payload, accessToken, base_url? } Proxies to partner API POST {base}/v2/applications; passes upstream status and body through, plus mapped DNS/SSL/timeout/HTML-error codes none of its own
GET /api/applications/[applicationId] Path param + Bearer header or ?accessToken= + ?base_url= Proxies GET {base}/v2/applications/{id}, upstream status and body passed through none of its own
POST /api/invoice { applicationId, payload, accessToken, base_url? } Proxies POST {base}/v2/applications/{id}/invoice none of its own
POST /api/auth/token { bearer_token: string } 200 {access_token: bearer_token, expires_in:3600}, echoed straight back, not verified against anything none
README drift: the README documents only /api/submit and /api/validate. Both are the two dead ones. The four routes actually in use (applications, applications/[id], invoice, auth/token) are undocumented.

Test summary

Test files
9/14
23 files total, passed / failed
Individual tests
515/60
575 tests total, passed / failed
The runner itself was broken before any test ran. npm test fails immediately on this machine's default Node (v18.12.0): vitest 4's config loader hits ERR_REQUIRE_ESM on a transitive dependency (std-env), before a single test file loads. The numbers above are from re-running under Node v20.20.1 (already installed via nvm, nothing in the repo changed). Separately, azure-pipelines.yml has six stages (SonarQube, security scan, build and push, Trivy scan, image signing, deploy) and none of them run npm test or vitest. Nothing here gates a merge or a deploy on tests passing, on any Node version.

Most of the 14 failing files are a stale, duplicate test tree (__tests__/components/steps/*, __tests__/components/overlay/*) left over from a path refactor: the live components moved to components/checkout/steps/ and components/checkout/overlay/, matching current tests exist at the same new paths, and the old-path copies were never deleted. One failure set is a live regression, not stale leftovers: see risk 3.

Top 3 risks, ranked

1 HIGH Checkout PII is broadcast to any origin communication.ts:13,61

lib/iframe/communication.ts:13 sets allowedOrigins to ['*'] by default, and app/checkout/page.tsx:75 calls useIframeCommunication({...}) without ever passing a real allowedOrigins list, so that default never gets tightened. In the other direction, sendToParent at communication.ts:61 always calls window.parent.postMessage(dataJSON, '*'), without origin restriction. notifyFormUpdate fires on every field change in every step, so name, date of birth, SSN, mobile number, and bank routing/account number all leave the iframe unrestricted, on every keystroke. The iframe also accepts a forged SDK_CART_SET or INIT message from any origin, including one carrying kafene_bearer_token.

Proposed fix
--- a/lib/iframe/communication.ts
+++ b/lib/iframe/communication.ts
@@ -12,71 +12,88 @@
 // Allowed origins for security (should be configured from parent)
 let allowedOrigins: string[] = ['*'];

+// Origin of the frame that actually embedded us. Captured only from a
+// message whose event.source is verified to be window.parent, so it
+// can't be spoofed by a forged origin string or a race from another window.
+let trustedParentOrigin: string | null = null;
+
 /**
  * Set allowed origins for postMessage communication
  */
 export function setAllowedOrigins(origins: string[]) {
   allowedOrigins = origins;
 }

 /**
  * Validate message origin
  */
 function isValidOrigin(origin: string): boolean {
   if (allowedOrigins.includes('*')) {
     return true;
   }
   return allowedOrigins.includes(origin);
 }

 /**
  * Send message to parent window
  */
 export function sendToParent<T>(
   type: IframeMessageType,
   payload: T
 ): void {
   if (typeof window === 'undefined' || !window.parent) {
     return;
   }

   const message: IframeMessage<T> = {
     type,
     payload,
     timestamp: Date.now(),
     messageId: `${type}-${Date.now()}`,
   };

   // Stringify message to match kafene.js displayMessage expectation
   // kafene.js expects JSON string, not object
   const dataJSON = JSON.stringify(message, function(key, val) {
     if (typeof val === 'function') {
       return val + '';
     }
     return val;
   });

-  // Send to parent with origin wildcard for development
-  // In production, use specific origin
-  window.parent.postMessage(dataJSON, '*');
+  // Target the real parent origin once verified via event.source in the
+  // inbound handler below. '*' remains only for the very first outbound
+  // ping (notifyReady), before any parent message has been confirmed.
+  window.parent.postMessage(dataJSON, trustedParentOrigin ?? '*');
 }

 /**
  * Listen for messages from parent window
  */
 export function listenToParent(
   callback: (message: IframeMessage) => void
 ): () => void {
   if (typeof window === 'undefined') {
     return () => {};
   }

   const handler = (event: MessageEvent) => {
+    // Reject anything that didn't come from our actual parent frame.
+    // event.source is a live reference set by the browser, so unlike
+    // event.origin it can't be forged by a malicious sender.
+    if (event.source !== window.parent) {
+      return;
+    }
+
     // Validate origin
     if (!isValidOrigin(event.origin)) {
       return;
     }

+    if (!trustedParentOrigin) {
+      trustedParentOrigin = event.origin;
+    }
+
     // Parse message data (kafene.js sends JSON strings)
     let messageData: IframeMessage;
     try {

The event.source === window.parent check is the real fix, not the origin capture: allowedOrigins staying at ['*'] would otherwise let any window that sends a message first get trusted as "the parent." event.source is a live reference set by the browser itself, so it can't be spoofed the way an origin string can. Also not shown: app/checkout/page.tsx should still pass a real allowedOrigins list, the merchant's configured embed origin, into useIframeCommunication, as defense in depth alongside this check.

Revised after a Codex review of this page found the first version of this fix insufficient: it captured "the first accepted origin" without tightening allowedOrigins, so it didn't actually stop a forged sender. Six lower-severity findings from that same review (a color-contrast issue, a wording overstatement, a JS ordering bug in the diff-coloring script below, a mobile CSS overflow, a missing aria-hidden) are noted but not yet applied here.

2 HIGH Proxy routes will fetch any URL the caller names applications/route.ts:28, invoice/route.ts:35, [id]/route.ts:47

app/api/applications/route.ts:28-29, app/api/invoice/route.ts:35-36, and app/api/applications/[applicationId]/route.ts:47-48 each build a server-side fetch() URL directly from a client-supplied base_url, with no allowlist of accepted hosts. None of the three routes verify the caller's accessToken against anything either; they trust it and forward it as a Bearer header. Anyone who can reach these routes directly, not only through the iframe, controls both which host the server contacts and what credential it presents there.

Worth checking by hand: whether these routes sit behind a network boundary (a WAF rule, a proxy allowlist) that isn't visible in this repo, since that would change how exploitable this is in practice.

3 HIGH The test gate is broken, and a real bug shipped through the gap schemas.ts:34,51 / schemas.test.ts:312-657

Two separate breaks compound here. First, tests cannot run at all on this machine's default Node version (see the test summary above), and CI never invokes them on any version. Second, lib/validation/schemas.ts:34 renamed a field to annualIncome, but __tests__/lib/validation/schemas.test.ts, the one current test file for this schema, still posts monthlyIncome in every payload (lines 312-657). Every paymentInfoSchema and checkoutSchema assertion now fails with a generic "Required" instead of exercising the rule it claims to check: 18 failing assertions covering income, routing number, and account number validation. Separately, and still live, schemas.ts:51 now accepts an account number as short as 5 digits, where the tests (and the message text next to the rule) expect an 8-digit floor.

Because nothing runs this file locally by default and nothing runs it in CI at all, a change like this can land, merge, and deploy with zero signal that the field it validates has drifted from what's tested.

Seven more, lower-severity findings turned up in this pass (dead code paths, an unreachable close-confirmation dialog, an unused API client, a duplicate component file) and were left out to keep this list to three. Ask if you want the full list.