From 28db38a117661c9286c0ac5dc9aabd22773a9c2f Mon Sep 17 00:00:00 2001 From: hiroTamada <88675973+hiroTamada@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:57:50 +0000 Subject: [PATCH 1/2] Document embedded per-purchase CVC collection --- docs.json | 3 +- .../wallets/embedded-card-capture.mdx | 11 +- integrations/wallets/embedded-card-cvc.mdx | 223 ++++++++++++++++++ 3 files changed, 232 insertions(+), 5 deletions(-) create mode 100644 integrations/wallets/embedded-card-cvc.mdx diff --git a/docs.json b/docs.json index c06a7208..69f83da0 100644 --- a/docs.json +++ b/docs.json @@ -208,7 +208,8 @@ "integrations/wallets/overview", "integrations/wallets/stripe-link", "integrations/wallets/agentcard", - "integrations/wallets/embedded-card-capture" + "integrations/wallets/embedded-card-capture", + "integrations/wallets/embedded-card-cvc" ] }, "config-registry" diff --git a/integrations/wallets/embedded-card-capture.mdx b/integrations/wallets/embedded-card-capture.mdx index c3dc9206..7f9f9953 100644 --- a/integrations/wallets/embedded-card-capture.mdx +++ b/integrations/wallets/embedded-card-capture.mdx @@ -15,10 +15,13 @@ use the embedded component when: use `action.url` instead when a hosted page is acceptable. it needs no frontend work. - only card entry is embeddable. visa purchases still send the cardholder to - the KERNEL-hosted `spend_approval` page, because visa's authentication runs - in its own iframe and passkey window there. mastercard purchases don't use - that page. + card enrollment and [per-purchase CVC + collection](/integrations/wallets/embedded-card-cvc) are embeddable. + alias-mode purchases on any card network require fresh CVC collection, either + embedded or through the hosted fallback. Visa network-token authentication + still uses the KERNEL-hosted `spend_approval` page with its own iframe and + passkey window. Network-token Mastercard purchases don't need that hosted + approval step. ## Install diff --git a/integrations/wallets/embedded-card-cvc.mdx b/integrations/wallets/embedded-card-cvc.mdx new file mode 100644 index 00000000..045ffb0b --- /dev/null +++ b/integrations/wallets/embedded-card-cvc.mdx @@ -0,0 +1,223 @@ +--- +title: "Embedded Card CVC" +description: "Collect a per-purchase security code on your own branded page" +--- + +Alias-mode purchases with a KERNEL wallet (`provider: "kernel"`) require a fresh CVC for each purchase, on any card network. Authorization returns `pending_authorization` with a `spend_approval` action. Render [`@onkernel/vault-react`](https://www.npmjs.com/package/@onkernel/vault-react)'s `KernelCardCVC` component to keep this step inside your application, or open `action.url` for the hosted fallback. + +The cardholder enters the code in a Very Good Security (VGS) iframe. Your page, your servers, and KERNEL never receive the raw CVC. VGS sends KERNEL a temporary alias bound to this purchase. The component doesn't collect a card number, enroll a wallet, or submit merchant checkout. + + + Use this component only for the CVC action whose URL points to `/card-cvc` on + your configured KERNEL vault origin. Other spend approvals, including Visa + network-token authentication, still use the hosted URL. Don't select a CVC + flow by action name or card network alone. Use [embedded card + capture](/integrations/wallets/embedded-card-capture) for enrollment. + + +## Install + +```bash +bun add @onkernel/vault-react@^0.4.0 +# or: npm install @onkernel/vault-react@^0.4.0 +``` + +The package requires React 18 or later and ships no CSS. + +## Deliver the capability from your backend + +After authorizing the purchase, retrieve it from your authenticated backend. There isn't a dedicated `action.cvc_token` field yet. For this CVC flow, extract the `token` parameter from the action URL's fragment and deliver it only to the intended cardholder's authenticated application. Validate the origin and `/card-cvc` path before selecting this component: + + + +```typescript TypeScript +const purchase = await kernel.vaults.items.retrieve("purchase-12345", { + id_or_name: "user-12345", +}); + +if ( + purchase.type === "card" && + purchase.state.status === "pending_authorization" && + purchase.action?.name === "spend_approval" +) { + const actionURL = new URL(purchase.action.url); + if ( + actionURL.origin === "https://vault.kernel.sh" && + actionURL.pathname === "/card-cvc" + ) { + const cvcToken = new URLSearchParams(actionURL.hash.slice(1)).get("token"); + if (cvcToken && /^[a-z0-9]{1,64}\.[A-Za-z0-9_-]{43}$/.test(cvcToken)) { + // Return cvcToken only to this purchase's cardholder. + } + } +} +``` + +```python Python +import re +from urllib.parse import parse_qs, urlparse + +purchase = kernel.vaults.items.retrieve( + "purchase-12345", + id_or_name="user-12345", +) + +if ( + purchase.type == "card" + and purchase.state.status == "pending_authorization" + and purchase.action is not None + and purchase.action.name == "spend_approval" +): + action_url = urlparse(purchase.action.url) + if ( + action_url.scheme == "https" + and action_url.netloc == "vault.kernel.sh" + and action_url.path == "/card-cvc" + ): + cvc_token = parse_qs(action_url.fragment).get("token", [None])[0] + if cvc_token and re.fullmatch(r"[a-z0-9]{1,64}\.[A-Za-z0-9_-]{43}", cvc_token): + # Return cvc_token only to this purchase's cardholder. + pass +``` + + + +Use your configured vault origin instead of `https://vault.kernel.sh` when targeting another environment. This fragment extraction is specific to the current CVC integration; it doesn't change enrollment's `action.capture_token` contract. `cvcToken` is a one-purchase capability, not an API key or enrollment token. Keep the action URL available as a hosted fallback. Don't send either capability to your automation agent. + +## Render the component + +```tsx ConfirmPurchase.tsx +"use client"; + +import { KernelCardCVC } from "@onkernel/vault-react"; + +export function ConfirmPurchase({ cvcToken }: { cvcToken: string }) { + return ( + { + // Ask your backend to retrieve the purchase before resuming checkout. + }} + onError={(code) => { + // Handle the fixed code, not raw VGS or API responses. + }} + /> + ); +} +``` + +The server-derived merchant, amount, and card's last four digits remain visible. Callbacks never include the code, alias, token, or provider response body. + +| Prop | Description | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `token` | Required purchase capability from the CVC action URL fragment | +| `baseUrl` | API origin; defaults to `https://api.onkernel.com`. HTTPS required; HTTP loopback is allowed for local development. Paths, query strings, fragments, and URL credentials aren't allowed. | +| `className` | Root class in both form and completion views | +| `fieldCss` | CSS inside the single VGS CVC iframe; read at mount | +| `labels` | Partial text overrides; see below | +| `onComplete` | Runs once after a 2xx response with `status: "approved"` confirms collection, not payment | +| `onError` | Receives only a fixed error code | + +The component reads `GET /vault/card-cvc/current` directly with the bearer and `credentials: "omit"`. Don't route reads through your backend: the API rate-limits per client address, so a proxy makes cardholders share its limit. The iframe submits through VGS, not through your page or backend. + +Changing `token` or `baseUrl` unmounts the old field and resets the component. Don't change them during submission. An unmounted component ignores late callbacks; retrieve purchase state from your backend if you don't know whether collection completed. + +## Style the component + +Style the surrounding form through your `className`. Give the iframe container an explicit height: + +```css +.purchase-cvc .credential-field { + margin-bottom: 16px; +} +.purchase-cvc .credential-label { + display: block; + margin-bottom: 8px; +} +.purchase-cvc .card-field { + height: 48px; + border: 1px solid #d9d0f5; + padding: 0 12px; +} +.purchase-cvc .card-field iframe { + display: block; + width: 100%; + height: 100%; + border: 0; +} +``` + +| Class | Element | +| -------------------------------------------- | ---------------------------------------- | +| `card-cvc` / `credential-complete` | Root in form / completion view | +| `credential-header` | Title, purchase summary, and description | +| `credential-title`, `credential-description` | Headings and description text | +| `approval-summary` | Server-derived purchase summary | +| `credential-form` | Form | +| `credential-field`, `credential-label` | Field wrapper and label | +| `card-field` | VGS iframe container | +| `credential-primary`, `credential-retry` | Submit and initialization-retry buttons | +| `credential-error` | Fixed error message | + +Page CSS can't reach into the iframe. Use `fieldCss` for input styling, including nested selectors such as `&:focus`, `&.invalid.touched`, `&::placeholder`, media queries, and `@font-face`. Supply it before mounting; remount to change it. + +Override these `labels` keys: `title`, `description`, `completeTitle`, `completeDescription`, `cvc`, `cvcPlaceholder`, `submit`, `submitting`, `loading`, `retry`, and `missingToken`. Set the title, descriptions, and completion copy to your application's wording; defaults use KERNEL's lowercase copy and mention KERNEL. Error messages are fixed. + +## Allow the component in your CSP + +Merge these directives into your existing policy: + +```text +script-src 'self' 'nonce-' https://js.verygoodvault.com; +frame-src https://js.verygoodvault.com; +connect-src 'self' https://api.onkernel.com; +``` + +Collect loads a pinned, integrity-checked script and submits from its iframe origin. You don't need the inbound proxy hostname or Collect analytics endpoints in your page's `connect-src`. If you use external fonts, allow their source in the appropriate context and test the iframe rendering. The hosted fallback cannot itself be framed. + +## Handle completion and expiry + +`onComplete` confirms CVC collection, not a merchant payment. Retrieve the purchase from your backend and inspect `state.status === "ready"` and `expires_at` before resuming checkout. Fill and **submit checkout before `expires_at`**. + +The unused collection capability lasts 30 minutes. After capture, the purchase expires at the temporary CVC alias deadline, **five minutes by default**. Repeated fill or collection doesn't extend that deadline. An expired alias-mode purchase requires a new purchase and fresh authorization; refreshing an enrollment token won't recover it. + +If a dispatched submission has an unknown outcome, retrieve the existing purchase before taking any further action. Don't automatically recollect, create a second purchase, or repeat payment. + +## Handle errors + +| Code | Meaning | What to do | +| ------------------ | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `invalid` | Field validation failed | Correct the code and submit again | +| `rate_limited` | Too many attempts | Wait, then retry | +| `network` | View read or Collect initialization failed | Use the initialization retry button | +| `expired` | Purchase expired | Create and authorize a new purchase | +| `unavailable` | Capability is invalid or purchase is no longer available | Retrieve purchase state; obtain a new purchase if needed | +| `invalid_response` | Purchase view couldn't be decoded safely, or API origin is invalid | Stop and check your integration; keep the link for investigation | +| `unconfirmed` | A dispatched submission timed out, failed ambiguously, or returned an unrecognized response | Retrieve the existing purchase; don't automatically recollect or create another payment | + +The form is removed from use after `expired`, `unavailable`, `invalid_response`, or `unconfirmed`. The component doesn't resend CVC submissions on unmount or unload. A known field validation failure or definitive rate limit permits retry. + +## Security + +- Serve your page over HTTPS with no-store and no-referrer policies. +- Treat `cvc_token` as a secret. Never put it in a path, query string, cookie, browser storage, logs, traces, or analytics. If you deliver a link, use a fragment and clear it after completion or terminal invalidation. +- Don't add native CVC inputs, raw-value callbacks, analytics, or session replay around the component. Control scripts and dependencies that can modify the surrounding page. +- Raw CVC stays in the VGS-hosted field and VGS processing. Your page and KERNEL never receive it. This preserves the isolated-field boundary used by embedded card capture; it doesn't certify your PCI scope or SAQ eligibility. Confirm your integration's eligibility and script-security requirements with your acquirer or QSA. +- Don't configure VGS to accept every integrator origin. The submission origin is `https://js.verygoodvault.com`; no VGS credentials or callback secrets belong in your frontend. From 94fc05ca48aa145e06dddfbfce1c16c79eb6dde1 Mon Sep 17 00:00:00 2001 From: hiroTamada <88675973+hiroTamada@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:23:24 +0000 Subject: [PATCH 2/2] Clarify CVC capability, origin errors and submit timeout --- integrations/wallets/embedded-card-cvc.mdx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/integrations/wallets/embedded-card-cvc.mdx b/integrations/wallets/embedded-card-cvc.mdx index 045ffb0b..5d9557d8 100644 --- a/integrations/wallets/embedded-card-cvc.mdx +++ b/integrations/wallets/embedded-card-cvc.mdx @@ -26,7 +26,7 @@ The package requires React 18 or later and ships no CSS. ## Deliver the capability from your backend -After authorizing the purchase, retrieve it from your authenticated backend. There isn't a dedicated `action.cvc_token` field yet. For this CVC flow, extract the `token` parameter from the action URL's fragment and deliver it only to the intended cardholder's authenticated application. Validate the origin and `/card-cvc` path before selecting this component: +After authorizing the purchase, retrieve it from your authenticated backend. There isn't a dedicated `action.cvc_token` field yet. The fragment extraction below, including the `token` parameter name and validation regex, is provisional and will be replaced by a dedicated API field. For this interim CVC flow, extract the `token` parameter from the action URL's fragment and deliver it only to the intended cardholder's authenticated application. Validate the origin and `/card-cvc` path before selecting this component: @@ -135,6 +135,8 @@ The server-derived merchant, amount, and card's last four digits remain visible. | `onComplete` | Runs once after a 2xx response with `status: "approved"` confirms collection, not payment | | `onError` | Receives only a fixed error code | +`KernelCardCVC` requires `baseUrl` to be a bare origin, unlike `KernelCardCapture`, which accepts a path prefix. An invalid `baseUrl` throws a developer error during render, before sending the capability; it doesn't call `onError`. + The component reads `GET /vault/card-cvc/current` directly with the bearer and `credentials: "omit"`. Don't route reads through your backend: the API rate-limits per client address, so a proxy makes cardholders share its limit. The iframe submits through VGS, not through your page or backend. Changing `token` or `baseUrl` unmounts the old field and resets the component. Don't change them during submission. An unmounted component ignores late callbacks; retrieve purchase state from your backend if you don't know whether collection completed. @@ -198,6 +200,8 @@ Collect loads a pinned, integrity-checked script and submits from its iframe ori The unused collection capability lasts 30 minutes. After capture, the purchase expires at the temporary CVC alias deadline, **five minutes by default**. Repeated fill or collection doesn't extend that deadline. An expired alias-mode purchase requires a new purchase and fresh authorization; refreshing an enrollment token won't recover it. +Submission times out after 60 seconds: the component hides the form, calls `onError('unconfirmed')`, and ignores late approval callbacks. + If a dispatched submission has an unknown outcome, retrieve the existing purchase before taking any further action. Don't automatically recollect, create a second purchase, or repeat payment. ## Handle errors @@ -209,7 +213,7 @@ If a dispatched submission has an unknown outcome, retrieve the existing purchas | `network` | View read or Collect initialization failed | Use the initialization retry button | | `expired` | Purchase expired | Create and authorize a new purchase | | `unavailable` | Capability is invalid or purchase is no longer available | Retrieve purchase state; obtain a new purchase if needed | -| `invalid_response` | Purchase view couldn't be decoded safely, or API origin is invalid | Stop and check your integration; keep the link for investigation | +| `invalid_response` | Purchase view couldn't be decoded safely | Stop and check your integration; keep the link for investigation | | `unconfirmed` | A dispatched submission timed out, failed ambiguously, or returned an unrecognized response | Retrieve the existing purchase; don't automatically recollect or create another payment | The form is removed from use after `expired`, `unavailable`, `invalid_response`, or `unconfirmed`. The component doesn't resend CVC submissions on unmount or unload. A known field validation failure or definitive rate limit permits retry. @@ -217,7 +221,7 @@ The form is removed from use after `expired`, `unavailable`, `invalid_response`, ## Security - Serve your page over HTTPS with no-store and no-referrer policies. -- Treat `cvc_token` as a secret. Never put it in a path, query string, cookie, browser storage, logs, traces, or analytics. If you deliver a link, use a fragment and clear it after completion or terminal invalidation. +- Treat the CVC capability as a secret. Never put it in a path, query string, cookie, browser storage, logs, traces, or analytics. If you deliver a link, use a fragment and clear it after completion or terminal invalidation. - Don't add native CVC inputs, raw-value callbacks, analytics, or session replay around the component. Control scripts and dependencies that can modify the surrounding page. - Raw CVC stays in the VGS-hosted field and VGS processing. Your page and KERNEL never receive it. This preserves the isolated-field boundary used by embedded card capture; it doesn't certify your PCI scope or SAQ eligibility. Confirm your integration's eligibility and script-security requirements with your acquirer or QSA. - Don't configure VGS to accept every integrator origin. The submission origin is `https://js.verygoodvault.com`; no VGS credentials or callback secrets belong in your frontend.