diff --git a/cmd/vaults.go b/cmd/vaults.go index 91495c55..83db64ad 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -14,6 +14,7 @@ import ( "github.com/kernel/cli/pkg/util" kernel "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/param" "github.com/pterm/pterm" ) @@ -174,6 +175,11 @@ func (c VaultsCmd) ListItems(ctx context.Context, vault, output string) error { return nil } +// isVaultPurchase reports whether an item is a single purchase: a card or a payment_request. +func isVaultPurchase(item *kernel.VaultItemUnion) bool { + return item.Type == "card" || item.Type == "payment_request" +} + // vaultItemSite names the site a credential signs in to so agents can reuse it. func vaultItemSite(item kernel.VaultItemUnion) string { if item.Type != "credential" { @@ -244,16 +250,27 @@ func (c VaultsCmd) CreateWallet(ctx context.Context, vault, key string, spec ker return c.showItem(item, output, open) } -func (c VaultsCmd) SaveCard(ctx context.Context, vault, key string, spec kernel.CardVaultItemSpecUnionParam, update bool, output string) error { +// SaveCard creates or updates a card or payment_request item; both take the same provider specs. +func (c VaultsCmd) SaveCard(ctx context.Context, vault, key, itemType string, spec map[string]json.RawMessage, update bool, output string) error { var item *kernel.VaultItemUnion var err error - if update { - item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCardVaultItemUpdateRequest: &kernel.VaultItemUpdateParamsBodyCardVaultItemUpdateRequest{Type: "card", Spec: spec}}, option.WithMaxRetries(0)) + if itemType == "payment_request" { + requestSpec := param.Override[kernel.PaymentRequestVaultItemSpecUnionParam](spec) + if update { + item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfPaymentRequestVaultItemUpdateRequest: &kernel.VaultItemUpdateParamsBodyPaymentRequestVaultItemUpdateRequest{Type: "payment_request", Spec: requestSpec}}, option.WithMaxRetries(0)) + } else { + item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfPaymentRequest: &kernel.VaultItemUpsertParamsBodyPaymentRequest{Spec: requestSpec}}, option.WithMaxRetries(0)) + } } else { - item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCard: &kernel.VaultItemUpsertParamsBodyCard{Spec: spec}}, option.WithMaxRetries(0)) + cardSpec := param.Override[kernel.CardVaultItemSpecUnionParam](spec) + if update { + item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCardVaultItemUpdateRequest: &kernel.VaultItemUpdateParamsBodyCardVaultItemUpdateRequest{Type: "card", Spec: cardSpec}}, option.WithMaxRetries(0)) + } else { + item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCard: &kernel.VaultItemUpsertParamsBodyCard{Spec: cardSpec}}, option.WithMaxRetries(0)) + } } if err != nil { - if vaultSpecProvider(spec) == "kernel" { + if string(spec["provider"]) == `"kernel"` { return kernelVaultRequestError(err, "create") } return util.CleanedUpSdkError{Err: err} @@ -277,6 +294,9 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if operation == "confirm_transaction" && (params == nil || params.ConfirmTransaction == nil || open) { return fmt.Errorf("confirm_transaction requires --params and does not support --open") } + if operation == "remove_payment_method" && (params == nil || params.RemovePaymentMethod == nil || open) { + return fmt.Errorf("remove_payment_method requires payment_method_id and does not support --open") + } if isPasskeyOperation(operation) && (params == nil || params.Passkey == nil || open) { return fmt.Errorf("%s requires --browser-id and does not support --open", operation) } @@ -318,6 +338,9 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if isOnePasswordOperation(operation) { return fmt.Errorf("operation %q is not advertised in available_operations; inspect the item with items get -o json. For 1Password credentials, check that the linked credential_account is connected and the credential state; do not delete or recreate the item to reset it", operation) } + if operation == "add_payment_method" || operation == "remove_payment_method" { + return fmt.Errorf("operation %q is not advertised in available_operations; it is offered only on connected kernel wallets where payment methods are enabled, and scoped api keys also need vaults:write on the vault. a wallet holds at most 10 payment methods and 3 open enrollment links. inspect the wallet with wallets payment-methods", operation) + } return fmt.Errorf("operation %q is not advertised in available_operations; inspect the item", operation) } if operation == "confirm_transaction" && item.Spec.Provider == "kernel" { @@ -325,7 +348,7 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par return fmt.Errorf("confirm_transaction currency must match the approved purchase currency and amount must not exceed the approved purchase amount; operation was not invoked") } } - linkCard := item.Type == "card" && item.Spec.Provider == "link" + linkCard := isVaultPurchase(item) && item.Spec.Provider == "link" if params != nil && params.Authorize != nil && !linkCard { return fmt.Errorf("--browser-id and --page-url only apply to Link cards; authorize was not invoked") } @@ -363,6 +386,10 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par request.OfPrepareCheckout = &kernel.PrepareCheckoutVaultItemOperationRequestParam{Type: "prepare_checkout", Checkout: *params.Checkout} } else if operation == "collect" { request.OfCollect = &kernel.CollectVaultItemOperationRequestParam{Type: "collect"} + } else if operation == "add_payment_method" { + request.OfAddPaymentMethod = &kernel.AddPaymentMethodVaultItemOperationRequestParam{Type: kernel.AddPaymentMethodVaultItemOperationRequestTypeAddPaymentMethod} + } else if params != nil && params.RemovePaymentMethod != nil { + request.OfRemovePaymentMethod = params.RemovePaymentMethod } else if params != nil && params.Authorize != nil { request.OfAuthorize = params.Authorize } else { @@ -388,7 +415,14 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if isPasskeyOperation(operation) { return printVaultPasskeyOperationResult(response, operation, output) } - if response == nil || (response.Type != "card" && response.Type != "wallet" && response.Type != "credential" && response.Type != "credential_account") { + if operation == "add_payment_method" || operation == "remove_payment_method" { + actionURL, err := printVaultPaymentMethodOperationResult(response, operation, output) + if err != nil || !open { + return err + } + return c.openActionURL(actionURL, output) + } + if response == nil || (response.Type != "card" && response.Type != "payment_request" && response.Type != "wallet" && response.Type != "credential" && response.Type != "credential_account") { if item.Spec.Provider == "kernel" { return fmt.Errorf("unexpected vault operation response; %s", kernelVaultRetryGuidance(operation)) } @@ -404,6 +438,29 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par return c.showItem(&updated, output, open) } +func (c VaultsCmd) RemovePaymentMethod(ctx context.Context, vault, key, paymentMethodID string, yes bool, output string) error { + raw, err := json.Marshal(map[string]string{"payment_method_id": paymentMethodID}) + if err != nil { + return err + } + params, err := parseVaultOperationParams("remove_payment_method", string(raw), true, false) + if err != nil { + return err + } + if !yes { + label := "payment method " + paymentMethodID + " from wallet " + vault + "/" + key + ok, err := c.prompter.Confirm("remove "+label, "Remove "+label+" and delete its stored card?") + if err != nil { + return err + } + if !ok { + pterm.Info.Println("Removal cancelled") + return nil + } + } + return c.Invoke(ctx, vault, key, "remove_payment_method", params, output, false) +} + func (c VaultsCmd) Events(ctx context.Context, vault, key, after string, wait int64, output string) error { if err := validateVaultWait(wait); err != nil { return err @@ -448,7 +505,10 @@ func (c VaultsCmd) showItem(item *kernel.VaultItemUnion, output string, open boo if actions.RecoveryRequired { return nil } - actionURL := actions.ActionURL + return c.openActionURL(actions.ActionURL, output) +} + +func (c VaultsCmd) openActionURL(actionURL, output string) error { if actionURL == "" { if output != "json" { pterm.Info.Println("No action URL returned; no browser opened") diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 0e958b43..3358e5e9 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -5,8 +5,6 @@ import ( "fmt" "github.com/kernel/cli/pkg/interactive" - kernel "github.com/kernel/kernel-go-sdk" - "github.com/kernel/kernel-go-sdk/packages/param" "github.com/pkg/browser" "github.com/spf13/cobra" ) @@ -87,12 +85,17 @@ vault names, item keys, and project ownership are immutable. 1. create/select a vault, then create a provider wallet and follow its returned action. for kernel, share the hosted card_enrollment url; card details stay on that page. -2. inspect wallets payment-methods for eligibility and reasons before creating a card. - for Link, select a payment method id explicitly. + a kernel wallet that advertises add_payment_method starts empty: add each card with + wallets add-payment-method and share its card_enrollment url. +2. inspect wallets payment-methods for status, eligibility and reasons before creating a + card. for Link, select a payment method id explicitly. for kernel, payment_method_id + is optional on card items (omitted selects the default card, id card) and required on + payment_request items. 3. attach the vault with browsers create --vault ; attachment is required for authorizing a Link card at a checkout and for fill. for Link, reach the final checkout page before creating the card. -4. create a card request with --provider and --spec json. +4. create a card request with --provider and --spec json; add --type payment_request + to create a Link or kernel purchase as a payment_request item where enabled. 5. inspect items get, then use items invoke only when advertised. follow the operation description and any returned provider action. 6. ready Link and kernel cards use only advertised fill with --params for browser checkout. @@ -250,7 +253,17 @@ no other fields are accepted. ` + kernelTransactionHelp + `. restart_verification takes no parameters. in recovery_required, ` + vaultRecoveryHelp + ` it rotates the approval link without a provider call, only after a classified initial attestation failure with proof no intent was dispatched. other recovery states must not be retried. -collect/authorize/prepare_checkout/1pw_recover/restart_verification may use --open. +add_payment_method (kernel wallets, no parameters) starts hosted enrollment of a card and +returns the new payment method in pending_enrollment with a single-use card_enrollment url +that expires after 30 minutes. remove_payment_method (kernel wallets) requires --params or +--spec-file with payment_method_id only; it deletes that stored card and leaves the wallet's +other payment methods and status unchanged. 409 purchase_unresolved means a purchase funded +by the method must be reconciled first; 409 provider_revocation_pending means the method +stays removal_pending and the removal must be retried later. removal is not proof that a +payment did not occur. both are advertised only when payment methods are enabled, and +scoped api keys need vaults:write in addition to vaults:use on the vault (otherwise 403 +insufficient_scope). wallets add-payment-method and remove-payment-method are shortcuts. +collect/authorize/prepare_checkout/1pw_recover/restart_verification/add_payment_method may use --open. --open on card_enrollment or spend_approval is for a human cardholder on their own machine. agents must omit --open and share capability links privately, never open them for cardholders. fill returns value-free per-field outcomes; @@ -310,7 +323,9 @@ JSON kernel vaults items invoke user-vault login disarm_passkey --browser-id kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"browser-session-id","page_url":"https://shop.example/checkout","fields":[{"field":"number","selector":"#cardNumber"},{"field":"expiration","format":"MM/YY","selector":"#cardExpiry"},{"field":"cvc","selector":"#cardCvc"}]}' -o json kernel vaults items invoke checkout order-1 confirm_transaction --params '{"status":"APPROVED","transaction_type":"PURCHASE","amount":100,"currency":"USD","occurred_at":"2026-09-01T12:00:00Z"}' - kernel vaults items invoke checkout order-1 restart_verification --open`, + kernel vaults items invoke checkout order-1 restart_verification --open + kernel vaults items invoke checkout cardholder add_payment_method + kernel vaults items invoke checkout cardholder remove_payment_method --params '{"payment_method_id":""}'`, RunE: func(cmd *cobra.Command, args []string) error { open, _ := cmd.Flags().GetBool("open") raw, _ := cmd.Flags().GetString("params") @@ -320,7 +335,7 @@ JSON } if cmd.Flags().Changed("spec-file") { if !vaultOperationTakesParams(args[2]) { - return fmt.Errorf("--spec-file is only supported for fill, webmcp_invoke, prepare_checkout, confirm_transaction, and 1Password operations with parameters; passkey operations use --browser-id and optional --field") + return fmt.Errorf("--spec-file is only supported for fill, webmcp_invoke, prepare_checkout, confirm_transaction, remove_payment_method, and 1Password operations with parameters; passkey operations use --browser-id and optional --field") } data, err := readVaultJSONFile(cmd, "spec-file") if err != nil { @@ -347,7 +362,7 @@ JSON } return getVaultsHandler(cmd).Invoke(cmd.Context(), args[0], args[1], args[2], params, vaultOutput(cmd), open) }} - invoke.Flags().String("params", "", "operation parameters json for fill, webmcp_invoke, prepare_checkout, confirm_transaction, or 1pw_* (maximum 128 KiB); omit type and credential values; 1pw_update_access_token requires --spec-file") + invoke.Flags().String("params", "", "operation parameters json for fill, webmcp_invoke, prepare_checkout, confirm_transaction, remove_payment_method, or 1pw_* (maximum 128 KiB); omit type and credential values; 1pw_update_access_token requires --spec-file") invoke.Flags().String("spec-file", "", "operation parameters json file (use '-' for stdin; maximum 128 KiB)") invoke.MarkFlagsMutuallyExclusive("params", "spec-file") invoke.Flags().Bool("open", false, "open a returned https action url; cardholder links are human-only, agents must share privately") @@ -388,7 +403,7 @@ JSON walletCreate.Flags().Bool("open", false, "open a returned https connection/enrollment url; cardholder use only, agents must share privately") addVaultJSONOutputFlag(walletCreate) methods := &cobra.Command{Use: "payment-methods ", Short: "fetch advertised live wallet payment methods", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, - Long: "fetch payment_methods through the item's GET expansion. the wallet must advertise this expansion.\ndisplays selectable ids and advisory capabilities; never automatically chooses a funding method.\njson returns the item with expanded.payment_methods, like items get --expand payment_methods.", + Long: "fetch payment_methods through the item's GET expansion. the wallet must advertise this expansion.\ndisplays selectable ids and advisory capabilities; never automatically chooses a funding method.\nkernel wallets list their cards in creation order with status (pending_enrollment, active, removal_pending, recovery_required), status_reason, and a pending card's card_enrollment url and expiry; a card that is not active is ineligible.\njson returns the item with expanded.payment_methods, like items get --expand payment_methods.", RunE: func(cmd *cobra.Command, args []string) error { project, _ := cmd.Flags().GetString("project") return getVaultsHandler(cmd).GetItem(cmd.Context(), args[0], args[1], 0, []string{"payment_methods"}, resolveProjectSelection(project), vaultOutput(cmd), false) @@ -404,7 +419,28 @@ JSON walletGet.Flags().Int64("wait", 0, "hold while pending for up to this many seconds (0-60); observe only") walletGet.Flags().Bool("open", false, "open a returned https enrollment url; cardholder use only, agents must share privately") addVaultJSONOutputFlag(walletGet) - wallets.AddCommand(walletCreate, walletGet, methods) + addMethod := &cobra.Command{Use: "add-payment-method ", Short: "start hosted enrollment of a card in a kernel wallet", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: "invoke add_payment_method on a kernel wallet that advertises it, the same as items invoke add_payment_method.\nreturns a new payment method in pending_enrollment with a card_enrollment url.\n" + kernelEnrollmentHelp + ".\na wallet holds at most 10 payment methods and 3 open enrollment links.\nscoped api keys need vaults:write in addition to vaults:use on the vault.", + Example: " kernel vaults wallets add-payment-method checkout cardholder", + RunE: func(cmd *cobra.Command, args []string) error { + open, _ := cmd.Flags().GetBool("open") + return getVaultsHandler(cmd).Invoke(cmd.Context(), args[0], args[1], "add_payment_method", nil, vaultOutput(cmd), open) + }} + addMethod.Flags().Bool("open", false, "open the returned https enrollment url; cardholder use only, agents must share privately") + addVaultJSONOutputFlag(addMethod) + removeMethod := &cobra.Command{Use: "remove-payment-method --payment-method-id ", Short: "remove one card from a kernel wallet", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: "invoke remove_payment_method on a kernel wallet that advertises it, the same as items invoke remove_payment_method --params '{\"payment_method_id\":\"\"}'.\nremoval deletes the stored card; " + kernelRemovedPaymentMethodHelp + ".\n409 purchase_unresolved: a purchase funded by the method has an unresolved outcome; reconcile it before removing.\n409 provider_revocation_pending: the method stays removal_pending and refuses payments; retry the removal later.\nscoped api keys need vaults:write in addition to vaults:use on the vault.", + Example: " kernel vaults wallets remove-payment-method checkout cardholder --payment-method-id ", + RunE: func(cmd *cobra.Command, args []string) error { + id, _ := cmd.Flags().GetString("payment-method-id") + yes, _ := cmd.Flags().GetBool("yes") + return getVaultsHandler(cmd).RemovePaymentMethod(cmd.Context(), args[0], args[1], id, yes, vaultOutput(cmd)) + }} + removeMethod.Flags().String("payment-method-id", "", "payment method id from wallets payment-methods (required)") + _ = removeMethod.MarkFlagRequired("payment-method-id") + removeMethod.Flags().BoolP("yes", "y", false, "skip confirmation prompt") + addVaultJSONOutputFlag(removeMethod) + wallets.AddCommand(walletCreate, walletGet, methods, addMethod, removeMethod) cards := &cobra.Command{Use: "cards", Short: "configure card requests"} cards.AddCommand(newVaultCardCommand(false), newVaultCardCommand(true)) @@ -439,6 +475,27 @@ func newVaultCardCommand(update bool) *cobra.Command { if update { providers = "link|agentcard" } + example := " kernel vaults cards " + use + ` checkout order-1 \ + --provider agentcard --spec '{ + "wallet": "wallet-1", + "merchant": "Example Shop", + "amount": 1234, + "currency": "usd" + }'` + if !update { + example += ` + + kernel vaults cards create checkout order-2 --type payment_request \ + --provider kernel --spec '{ + "wallet": "cardholder", + "payment_method_id": "", + "amount": 1200, + "currency": "usd", + "merchant_name": "Example Shop", + "merchant_url": "https://shop.example/checkout", + "merchant_country": "US" + }'` + } cmd := &cobra.Command{Use: use + " --provider <" + providers + "> --spec ''", Short: short, Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, Long: short + `. neither create nor update authorizes a card. kernel cards cannot be updated. other requested cards accept a replacement spec. @@ -451,24 +508,26 @@ never reconfigure the same item to retry a failed, timed-out, rejected, or indet an AgentCard recovery item that permits abandonment must be deleted after explicit user confirmation before creating a replacement. ` + linkCardFlowHelp + "\n" + vaultSpecHelp + vaultCardSpecHelp, - Example: " kernel vaults cards " + use + ` checkout order-1 \ - --provider agentcard --spec '{ - "wallet": "wallet-1", - "merchant": "Example Shop", - "amount": 1234, - "currency": "usd" - }'`, + Example: example, RunE: func(cmd *cobra.Command, args []string) error { spec, err := vaultSpecFromFlags(cmd) if err != nil { return err } + itemType, _ := cmd.Flags().GetString("type") + if itemType != "card" && itemType != "payment_request" { + return fmt.Errorf("--type must be card or payment_request") + } + if itemType == "payment_request" && string(spec["provider"]) == `"agentcard"` { + return fmt.Errorf("payment_request items support link and kernel; AgentCard reusable cards remain card items") + } if update && string(spec["provider"]) == `"kernel"` { return fmt.Errorf("kernel card updates are not supported; " + kernelReplacementHelp) } - return getVaultsHandler(cmd).SaveCard(cmd.Context(), args[0], args[1], param.Override[kernel.CardVaultItemSpecUnionParam](spec), update, vaultOutput(cmd)) + return getVaultsHandler(cmd).SaveCard(cmd.Context(), args[0], args[1], itemType, spec, update, vaultOutput(cmd)) }} addVaultSpecFlags(cmd) + cmd.Flags().String("type", "card", "item type: card, or payment_request for link and kernel purchases; an update must match the stored type") addVaultJSONOutputFlag(cmd) return cmd } diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index 538e616e..6fc43add 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -15,10 +15,14 @@ or normalization. never include card data, OAuth tokens, or provider secrets in const vaultRecoveryHelp = "only if restart_verification is advertised in available_operations, invoke it with explicit user approval each time to get a fresh spend_approval link. restart grants no spend consent: the cardholder must verify and explicitly approve spending again on the new link. if the result is unclear, re-get and share the new action if present; invoke once more only if restart_verification is still advertised and the user approves again. otherwise " + kernelSupportGuidance const vaultWalletSpecHelp = ` -for --provider kernel, use --spec '{}'. share the returned card_enrollment url -with the cardholder; never enter card details in the cli. use wallets get --wait 60 -to observe connection, then wallets payment-methods to inspect token eligibility. -a connected wallet may still be ineligible while network token enrollment runs. +for --provider kernel, use --spec '{}'. when payment methods are enabled for the +organization, creation returns an empty connected wallet that advertises +add_payment_method: add each card with wallets add-payment-method and share its +card_enrollment url with the cardholder. otherwise creation returns the default +card's card_enrollment url; share it and use wallets get --wait 60 to observe +connection. never enter card details in the cli. use wallets payment-methods to +inspect each card's status and token eligibility; a card may still be ineligible +while network token enrollment runs. ` + kernelWalletHelp + `. ` + kernelEligibilityHelp + `. kernel wallets accept neither provider configuration flags nor --tokens-file. for Link and AgentCard, omit config selection to preserve managed defaults. @@ -54,11 +58,19 @@ type AgentCardWalletSpec = { user_id?: string; // usr_...; enrolled in this organization under the same config }; -// one card stored with KERNEL-managed credentials. creation returns a card_enrollment -// action: the cardholder enters the card on a KERNEL-hosted page, and the wallet -// connects once the card is stored. KERNEL then enrolls it for an agentic network -// token when the issuer supports it; until then payment-methods reports +// zero or more cards stored with KERNEL-managed credentials, each a payment method of +// the wallet. each card is enrolled through a card_enrollment action: the cardholder +// enters the card on a KERNEL-hosted page. when the wallet advertises +// add_payment_method, creation returns an empty connected wallet and each card is +// added with add_payment_method (at most 10 methods and 3 open enrollment links) +// and removed with remove_payment_method; scoped api keys need vaults:write in +// addition to vaults:use for both. otherwise creation returns the card_enrollment +// action of the wallet's default card, payment method id card, and the wallet +// connects once that card is stored. KERNEL then enrolls each card for an agentic +// network token when the issuer supports it; until then payment-methods reports // capabilities.single_use_card.eligible=false and authorize returns 400. +// payment-methods reports each card's status: pending_enrollment (link open), +// active, removal_pending (retry the removal) or recovery_required (see status_reason). // the card number never reaches KERNEL or the cli. no provider config or tokens. type KernelWalletSpec = { provider: "kernel"; @@ -107,6 +119,8 @@ type AgentCardCardSpec = { type KernelCardSpec = { provider: "kernel"; wallet: string; // KERNEL wallet item key + payment_method_id?: string; // from wallets payment-methods; omit for the default card, id card. + // never falls back to another card; required by --type payment_request amount: number; // integer minor units; 1..1000000 currency: string; // iso 4217 three letters merchant_name: string; // 1..255 characters @@ -141,6 +155,16 @@ first revokes the Link credential so later charges decline. if deletion returns provider_revocation_pending, retry deletion. deletion does not cancel the merchant subscription. recurring terms cannot be changed after authorization. +--type payment_request creates a Link or kernel purchase as a payment_request item, +where payment requests are enabled for the organization. it takes the same LinkCardSpec +or KernelCardSpec, has the same operations, and kernel requests must name an active, +eligible payment_method_id. AgentCard reusable cards remain card items. an update must +use the stored item's type. a kernel purchase stays bound to its wallet and payment method. +kernel purchases report state.payment_method_id and state.funding_status: available; +unavailable while the method is being removed or awaits recovery (authorize, fill and +confirm_transaction are refused); removed once the method is removed (create a new +purchase with another method). funding_status never changes the purchase's status. + kernel cards cannot be updated. ` + kernelReplacementHelp + `. in recovery_required, ` + vaultRecoveryHelp + ` invoke authorize only when advertised. for Visa, present the returned diff --git a/cmd/vaults_invoke_test.go b/cmd/vaults_invoke_test.go index 979475a3..ef7563a8 100644 --- a/cmd/vaults_invoke_test.go +++ b/cmd/vaults_invoke_test.go @@ -234,3 +234,37 @@ func TestParseVaultConfirmTransactionParamsValidates(t *testing.T) { require.ErrorContains(t, err, message, raw) } } + +func TestVaultInvokeOpensAddedPaymentMethodEnrollment(t *testing.T) { + for _, open := range []bool{false, true} { + t.Run(fmt.Sprint(open), func(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + body := kernelMethodsWalletFixture + if r.Method == http.MethodPost { + body = kernelAddedMethodFixture + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, body) + }) + opened := "" + c := VaultsCmd{vaults: &client.Vaults, openURL: func(url string) error { opened = url; return nil }} + var err error + captureStdout(t, func() { + err = c.Invoke(context.Background(), "checkout", "cardholder", "add_payment_method", nil, "json", open) + }) + require.NoError(t, err) + if open { + assert.Equal(t, "https://vault.example/card#token=pm-2.opaque", opened) + } else { + assert.Empty(t, opened) + } + }) + } +} + +func TestVaultInvokeRemovePaymentMethodRequiresID(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("request without payment_method_id reached API") }) + c := VaultsCmd{vaults: &client.Vaults} + err := c.Invoke(context.Background(), "checkout", "cardholder", "remove_payment_method", nil, "", false) + require.ErrorContains(t, err, "requires payment_method_id") +} diff --git a/cmd/vaults_kernel_errors.go b/cmd/vaults_kernel_errors.go index fe9d12d2..7501db0c 100644 --- a/cmd/vaults_kernel_errors.go +++ b/cmd/vaults_kernel_errors.go @@ -63,6 +63,10 @@ func kernelVaultRetryGuidance(operation string) string { guidance += ". share any new spend_approval privately; obtain user approval each time and new cardholder spend consent" } return guidance + case "add_payment_method": + return "list the wallet's payment methods with wallets payment-methods before adding another; a lost response may have created a pending_enrollment method, listed with its link until the link expires after 30 minutes. never add payment methods in an automatic loop" + case "remove_payment_method": + return "list the wallet's payment methods with wallets payment-methods; invoke remove_payment_method again only if the method is still listed, never in an automatic loop. removal is not proof that a payment did not occur" default: return "inspect item state and events before taking further action; no automatic retries" } @@ -82,6 +86,14 @@ func kernelVaultRequestError(err error, operation string) error { _ = json.Unmarshal([]byte(apiErr.RawJSON()), &body) detail := kernelVaultErrorURL.ReplaceAllString(body.Message, "[redacted url]") switch { + case apiErr.StatusCode == 403 && body.Code == "insufficient_scope" && (operation == "add_payment_method" || operation == "remove_payment_method"): + guidance = "nothing was changed. adding or removing payment methods requires an api key with vaults:write in addition to vaults:use on this vault" + case apiErr.StatusCode == 409 && body.Code == "purchase_unresolved": + guidance = "the payment method was not removed. reconcile the unresolved purchase it funded first: inspect that item and its events, report the observed outcome with confirm_transaction when advertised, or delete the purchase if the api permits it. then retry the removal once" + case apiErr.StatusCode == 409 && body.Code == "provider_revocation_pending": + guidance = "the payment method stays removal_pending and refuses payments; invoke remove_payment_method again later, never in an automatic loop. removal is not proof that a payment did not occur" + case apiErr.StatusCode == 409 && operation == "add_payment_method": + guidance = "no enrollment was started. a wallet holds at most 10 payment methods and 3 open enrollment links; remove an unused method or let open links expire before adding another. " + guidance case apiErr.StatusCode == 429: guidance = retryAfterWait(apiErr) + "; " + guidance case apiErr.StatusCode == 409 && operation == "authorize": @@ -112,9 +124,15 @@ const kernelSupportGuidance = "do not retry payment, delete, or replace the item const kernelTransactionHelp = "report one observed merchant outcome only when confirm_transaction is advertised. PENDING means the merchant explicitly confirms an accepted transaction is pending; ERROR means an explicit merchant transaction error, not transport loss or an ambiguous processing page. these are final reports, not polling states. unknown means no reliable merchant outcome: ask the user to check order confirmation, merchant account or statement, then report the confirmed outcome even after card expiry. never infer success from fill. if reporting fails or the response is lost, re-get and send the identical observed report once more only if confirm_transaction is freshly advertised; otherwise observe state/events (recorded, in flight or recovery). never repeat merchant checkout" -const kernelEligibilityHelp = "eligible=false means do not authorize. for network_token_pending, re-check later at most once in this attempt; network_token_unsupported or network_token_failed needs a different card. if still ineligible or unknown, ask the cardholder before enrolling a different card under a new wallet key; do not poll indefinitely or replay an uncertain purchase" +const kernelEligibilityHelp = "eligible=false means do not authorize. for network_token_pending, re-check later at most once in this attempt; network_token_unsupported or network_token_failed needs a different card. if still ineligible or unknown, ask the cardholder before enrolling a different card, with add_payment_method when the wallet advertises it or otherwise under a new wallet key; do not poll indefinitely or replay an uncertain purchase" -const kernelWalletHelp = "share card_enrollment only with the intended cardholder privately, never open it for them. reconnect_required: share the new card_enrollment action. degraded: with user confirmation, delete the wallet only if the api permits it before creating a new wallet under a new key for one enrollment; reconcile unresolved child cards first. connected does not imply token eligibility; inspect wallets payment-methods" +const kernelWalletHelp = "a connected kernel wallet holds zero or more cards as payment methods; connected does not imply that any card can pay, so inspect wallets payment-methods. when the wallet advertises add_payment_method and remove_payment_method, add each card with wallets add-payment-method and remove one with wallets remove-payment-method; scoped api keys need vaults:write in addition to vaults:use for both. otherwise the wallet enrolls one default card (payment method id card) through the card_enrollment action returned at creation and connects once it is stored. share card_enrollment only with the intended cardholder privately, never open it for them. degraded: the default card's enrollment outcome is unknown; with user confirmation, delete the wallet only if the api permits it before creating a new wallet under a new key; reconcile unresolved child cards first. reconnect_required: the wallet predates payment methods, and payment and deletion are refused until it is upgraded" + +const kernelPaymentMethodHelp = "kernel card items select a method with optional payment_method_id; omitting it selects the default card, id card, and never falls back to another card. payment_request items require payment_method_id. only an active method with single_use_card.eligible=true can fund a new purchase, and a purchase stays bound to its method. pending_enrollment: share its card_enrollment url privately with the cardholder. removal_pending: invoke remove_payment_method again later. recovery_required: read status_reason; remove the method if its enrollment outcome is unknown, or contact support@kernel.sh if its stored card cannot be read" + +const kernelEnrollmentHelp = "share the card_enrollment url only with the intended cardholder privately, never open it for them; it is single-use and expires after 30 minutes. card details go to KERNEL's hosted page, never to the cli. a failed or expired enrollment does not affect the wallet's other payment methods. observe the method with wallets payment-methods until it is active and eligible; an unused link expires on its own, so add another only when the cardholder needs a new link" + +const kernelRemovedPaymentMethodHelp = "removal blocks new purchases, fills and confirmations funded by this payment method; other payment methods are unaffected and the wallet stays connected. purchases it funded report funding_status removed and keep their status. removal is not proof that a payment did not occur" const kernelReplacementHelp = "after user confirmation, delete only if the api permits it and create one new card under a new key for a new purchase; reconcile unresolved outcomes first" diff --git a/cmd/vaults_kernel_payment_test.go b/cmd/vaults_kernel_payment_test.go index 31b8f441..654f2145 100644 --- a/cmd/vaults_kernel_payment_test.go +++ b/cmd/vaults_kernel_payment_test.go @@ -176,3 +176,192 @@ func TestKernelCardUpdateRejectedLocally(t *testing.T) { _, _, err := executeVaultCommand(t, client, "vaults", "cards", "update", "checkout", "order-1", "--provider", "kernel", "--spec", `{}`) require.ErrorContains(t, err, "kernel card updates are not supported") } + +const kernelMethodsWalletFixture = `{"id":"wallet-id","key":"cardholder","type":"wallet","spec":{"provider":"kernel"},"state":{"provider":"kernel","status":"connected"},"available_operations":[{"type":"add_payment_method","description":"Start hosted enrollment of a card in this wallet."},{"type":"remove_payment_method","description":"Remove one card from this wallet."}],"available_expansions":[{"type":"payment_methods","description":"List cards"}]}` +const kernelAddedMethodFixture = `{"type":"add_payment_method","payment_method":{"id":"pm-2","provider":"kernel","type":"card","is_default":false,"status":"pending_enrollment","display":{},"capabilities":{"single_use_card":{"eligible":false,"reasons":["enrollment_pending"]}},"action":{"name":"card_enrollment","url":"https://vault.example/card#token=pm-2.opaque","capture_token":"CAPTURE_SECRET"},"expires_at":"2026-10-28T12:30:00Z"}}` + +func TestKernelAddPaymentMethod(t *testing.T) { + for _, args := range [][]string{ + {"items", "invoke", "checkout", "cardholder", "add_payment_method"}, + {"wallets", "add-payment-method", "checkout", "cardholder"}, + } { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + response := kernelMethodsWalletFixture + if r.Method == http.MethodPost { + assert.Equal(t, "/vaults/checkout/items/cardholder/operations", r.URL.Path) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"add_payment_method"}`, string(body)) + response = kernelAddedMethodFixture + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, response) + }) + out, human, err := executeVaultCommand(t, client, append([]string{"vaults"}, args...)...) + require.NoError(t, err) + assert.Equal(t, 2, calls) + for _, text := range []string{"pm-2", "pending_enrollment", "https://vault.example/card#token=pm-2.opaque", "never open it for them", "30 minutes"} { + assert.Contains(t, human, text) + } + assert.NotContains(t, out+human, "CAPTURE_SECRET") + out, _, err = executeVaultCommand(t, client, append(append([]string{"vaults"}, args...), "-o", "json")...) + require.NoError(t, err) + assert.Contains(t, out, `"status": "pending_enrollment"`) + assert.Contains(t, out, `"expires_at": "2026-10-28T12:30:00Z"`) + assert.Contains(t, out, `"url": "https://vault.example/card#token=pm-2.opaque"`) + assert.NotContains(t, out, "capture_token") + assert.NotContains(t, out, "CAPTURE_SECRET") + } +} + +func TestKernelRemovePaymentMethod(t *testing.T) { + for _, args := range [][]string{ + {"items", "invoke", "checkout", "cardholder", "remove_payment_method", "--params", `{"payment_method_id":"pm-2"}`}, + {"wallets", "remove-payment-method", "checkout", "cardholder", "--payment-method-id", "pm-2", "--yes"}, + } { + posts := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + response := kernelMethodsWalletFixture + if r.Method == http.MethodPost { + posts++ + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"remove_payment_method","payment_method_id":"pm-2"}`, string(body)) + response = `{"type":"remove_payment_method","payment_method_id":"pm-2","status":"removed"}` + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, response) + }) + _, human, err := executeVaultCommand(t, client, append([]string{"vaults"}, args...)...) + require.NoError(t, err) + assert.Equal(t, 1, posts) + assert.Contains(t, human, "removed payment method pm-2") + assert.Contains(t, human, "not proof that a payment did not occur") + out, _, err := executeVaultCommand(t, client, append(append([]string{"vaults"}, args...), "-o", "json")...) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"remove_payment_method","payment_method_id":"pm-2","status":"removed"}`, out) + } +} + +func TestKernelPaymentMethodOperationsRequireAdvertisement(t *testing.T) { + for _, operation := range []string{"add_payment_method", "remove_payment_method"} { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, strings.Replace(kernelWalletFixture, `"pending_authorization"`, `"connected"`, 1)) + }) + args := []string{"vaults", "items", "invoke", "checkout", "cardholder", operation} + if operation == "remove_payment_method" { + args = append(args, "--params", `{"payment_method_id":"card"}`) + } + _, _, err := executeVaultCommand(t, client, args...) + require.ErrorContains(t, err, "is not advertised") + assert.Contains(t, err.Error(), "vaults:write") + assert.Contains(t, err.Error(), "payment methods are enabled") + } +} + +func TestKernelPaymentMethodOperationErrors(t *testing.T) { + for _, tc := range []struct { + operation, code, want string + status int + }{ + {"remove_payment_method", "purchase_unresolved", "reconcile the unresolved purchase", 409}, + {"remove_payment_method", "provider_revocation_pending", "stays removal_pending", 409}, + {"remove_payment_method", "provider_rate_limited", "Retry-After (30)", 429}, + {"remove_payment_method", "insufficient_scope", "vaults:write in addition to vaults:use", 403}, + {"add_payment_method", "insufficient_scope", "vaults:write in addition to vaults:use", 403}, + {"add_payment_method", "conflict", "at most 10 payment methods and 3 open enrollment links", 409}, + } { + t.Run(tc.operation+" "+tc.code, func(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = io.WriteString(w, kernelMethodsWalletFixture) + return + } + w.Header().Set("Retry-After", "30") + w.WriteHeader(tc.status) + _, _ = io.WriteString(w, `{"code":"`+tc.code+`","message":"refused"}`) + }) + args := []string{"vaults", "items", "invoke", "checkout", "cardholder", tc.operation} + if tc.operation == "remove_payment_method" { + args = append(args, "--params", `{"payment_method_id":"pm-2"}`) + } + _, _, err := executeVaultCommand(t, client, args...) + require.ErrorContains(t, err, tc.want) + assert.Contains(t, err.Error(), "refused") + }) + } +} + +func TestKernelPaymentMethodStatuses(t *testing.T) { + wallet := strings.TrimSuffix(kernelMethodsWalletFixture, "}") + `,"expanded":{"payment_methods":[ + {"id":"card","provider":"kernel","type":"card","is_default":true,"status":"active","display":{"brand":"visa","last4":"1234"},"capabilities":{"single_use_card":{"eligible":true,"reasons":[]}}}, + {"id":"pm-2","provider":"kernel","type":"card","is_default":false,"status":"pending_enrollment","display":{},"capabilities":{"single_use_card":{"eligible":false,"reasons":["enrollment_pending"]}},"action":{"name":"card_enrollment","url":"https://vault.example/card#token=pm-2.opaque","capture_token":"CAPTURE_SECRET"},"expires_at":"2026-10-28T12:30:00Z"}, + {"id":"pm-3","provider":"kernel","type":"card","is_default":false,"status":"recovery_required","status_reason":"provider outcome unknown; remove this payment method","display":{},"capabilities":{"single_use_card":{"eligible":false,"reasons":[]}}} + ]}}` + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "payment_methods", r.URL.Query().Get("expand")) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, wallet) + }) + out, human, err := executeVaultCommand(t, client, "vaults", "wallets", "payment-methods", "checkout", "cardholder") + require.NoError(t, err) + for _, text := range []string{"status", "active", "pending_enrollment", "recovery_required", "pm-3 status reason: provider outcome unknown", "card enrollment url for pm-2", "https://vault.example/card#token=pm-2.opaque", "payment_request items require payment_method_id", "--params ''"} { + assert.Contains(t, human, text) + } + assert.NotContains(t, out+human, "CAPTURE_SECRET") + out, _, err = executeVaultCommand(t, client, "vaults", "wallets", "payment-methods", "checkout", "cardholder", "-o", "json") + require.NoError(t, err) + for _, text := range []string{`"status": "recovery_required"`, `"status_reason": "provider outcome unknown; remove this payment method"`, `"expires_at": "2026-10-28T12:30:00Z"`, `"name": "card_enrollment"`} { + assert.Contains(t, out, text) + } + assert.NotContains(t, out, "CAPTURE_SECRET") +} + +func TestKernelPaymentRequestCreate(t *testing.T) { + spec := `{"wallet":"cardholder","payment_method_id":"pm-2","amount":1200,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example/checkout","merchant_country":"US"}` + fixture := strings.Replace(strings.Replace(kernelCardFixture, `"type":"card"`, `"type":"payment_request"`, 1), `"status":"requested"`, `"status":"requested","payment_method_id":"pm-2","funding_status":"removed"`, 1) + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"payment_request","spec":{"provider":"kernel",`+spec[1:]+`}`, string(body)) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, fixture) + }) + args := []string{"vaults", "cards", "create", "checkout", "order-1", "--type", "payment_request", "--provider", "kernel", "--spec", spec} + _, human, err := executeVaultCommand(t, client, args...) + require.NoError(t, err) + for _, text := range []string{"payment_request", "payment method id", "pm-2", "funding status", "funding_status removed", "Example Shop"} { + assert.Contains(t, human, text) + } + assert.NotContains(t, human, "a connected kernel wallet holds") + out, _, err := executeVaultCommand(t, client, append(args, "-o", "json")...) + require.NoError(t, err) + assert.Contains(t, out, `"funding_status": "removed"`) + assert.Contains(t, out, `"payment_method_id": "pm-2"`) +} + +func TestKernelPaymentRequestFlagValidation(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("invalid request reached API") }) + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"cards", "create", "checkout", "order-1", "--type", "purchase", "--provider", "link", "--spec", "{}"}, "--type must be card or payment_request"}, + {[]string{"cards", "create", "checkout", "order-1", "--type", "payment_request", "--provider", "agentcard", "--spec", "{}"}, "AgentCard reusable cards remain card items"}, + {[]string{"cards", "update", "checkout", "order-1", "--type", "payment_request", "--provider", "kernel", "--spec", "{}"}, "kernel card updates are not supported"}, + {[]string{"items", "invoke", "checkout", "cardholder", "remove_payment_method"}, "requires --params or --spec-file with payment_method_id"}, + {[]string{"items", "invoke", "checkout", "cardholder", "remove_payment_method", "--params", `{"payment_method_id":""}`}, "payment_method_id must be a non-empty id"}, + {[]string{"items", "invoke", "checkout", "cardholder", "remove_payment_method", "--params", `{"payment_method_id":"pm-2","force":true}`}, "only supported, non-duplicate properties"}, + {[]string{"items", "invoke", "checkout", "cardholder", "add_payment_method", "--params", `{}`}, "add_payment_method take no parameters"}, + {[]string{"wallets", "remove-payment-method", "checkout", "cardholder", "--payment-method-id", " ", "--yes"}, "payment_method_id must be a non-empty id"}, + } { + _, _, err := executeVaultCommand(t, client, append([]string{"vaults"}, tc.args...)...) + require.ErrorContains(t, err, tc.want) + } +} diff --git a/cmd/vaults_kernel_recovery_test.go b/cmd/vaults_kernel_recovery_test.go index 12448afe..bd66b8a5 100644 --- a/cmd/vaults_kernel_recovery_test.go +++ b/cmd/vaults_kernel_recovery_test.go @@ -220,7 +220,7 @@ func TestKernelRecoveryJSONAndStateGuidance(t *testing.T) { switch status { case "degraded", "reconnect_required": assert.Contains(t, human, "delete the wallet only if the api permits it") - assert.Contains(t, human, "share the new card_enrollment") + assert.Contains(t, human, "reconnect_required: the wallet predates payment methods") case "requested": assert.Contains(t, human, "Visa may require") assert.NotContains(t, human, "merchant decline") diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go index 96f4e2c2..3f3554ca 100644 --- a/cmd/vaults_operation_params.go +++ b/cmd/vaults_operation_params.go @@ -12,11 +12,12 @@ import ( ) type vaultOperationParams struct { - Authorize *kernel.AuthorizeVaultItemOperationRequestParam - Fill *vaultFillParams - WebMCP *kernel.WebmcpInvokeVaultItemOperationRequestParam - Checkout *kernel.VaultCheckoutContextParam - ConfirmTransaction *vaultConfirmTransactionParams + Authorize *kernel.AuthorizeVaultItemOperationRequestParam + Fill *vaultFillParams + WebMCP *kernel.WebmcpInvokeVaultItemOperationRequestParam + Checkout *kernel.VaultCheckoutContextParam + ConfirmTransaction *vaultConfirmTransactionParams + RemovePaymentMethod *kernel.RemovePaymentMethodVaultItemOperationRequestParam // Passkey and OnePassword are complete operation request bodies; Invoke supplies the vault. Passkey *kernel.VaultItemPerformOperationParams OnePassword *kernel.VaultItemPerformOperationParams @@ -28,7 +29,7 @@ func isOnePasswordOperation(operation string) bool { // vaultOperationTakesParams reports whether an operation accepts --params or --spec-file. func vaultOperationTakesParams(operation string) bool { - return operation == "fill" || operation == "webmcp_invoke" || operation == "prepare_checkout" || operation == "confirm_transaction" || (isOnePasswordOperation(operation) && operation != "1pw_recover") + return operation == "fill" || operation == "webmcp_invoke" || operation == "prepare_checkout" || operation == "confirm_transaction" || operation == "remove_payment_method" || (isOnePasswordOperation(operation) && operation != "1pw_recover") } type vaultFillParams struct { @@ -88,8 +89,8 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( if strings.TrimSpace(operation) == "" { return nil, fmt.Errorf("operation must not be empty") } - if openSet && operation != "authorize" && operation != "collect" && operation != "prepare_checkout" && operation != "1pw_recover" && operation != "restart_verification" { - return nil, fmt.Errorf("--open is only supported for authorize, collect, prepare_checkout, 1pw_recover, and restart_verification") + if openSet && operation != "authorize" && operation != "collect" && operation != "prepare_checkout" && operation != "1pw_recover" && operation != "restart_verification" && operation != "add_payment_method" { + return nil, fmt.Errorf("--open is only supported for authorize, collect, prepare_checkout, 1pw_recover, restart_verification, and add_payment_method") } if len(raw) > 128*1024 { return nil, fmt.Errorf("operation parameters exceed 128 KiB") @@ -130,6 +131,20 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( } return &vaultOperationParams{ConfirmTransaction: confirmation}, nil } + if operation == "remove_payment_method" { + if !paramsSet { + return nil, fmt.Errorf("remove_payment_method requires --params or --spec-file with payment_method_id, or use wallets remove-payment-method") + } + object, err := vaultParamsObject(raw, "payment_method_id") + if err != nil { + return nil, err + } + request := kernel.RemovePaymentMethodVaultItemOperationRequestParam{Type: kernel.RemovePaymentMethodVaultItemOperationRequestTypeRemovePaymentMethod} + if json.Unmarshal(object["payment_method_id"], &request.PaymentMethodID) != nil || strings.TrimSpace(request.PaymentMethodID) == "" || len(request.PaymentMethodID) > 128 { + return nil, fmt.Errorf("payment_method_id must be a non-empty id of at most 128 bytes from wallets payment-methods") + } + return &vaultOperationParams{RemovePaymentMethod: &request}, nil + } if operation == "prepare_checkout" { if !paramsSet { return nil, fmt.Errorf("prepare_checkout requires --params or --spec-file with checkout") @@ -152,7 +167,7 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( } if operation != "fill" { if paramsSet { - return nil, fmt.Errorf("--params is only supported for fill, webmcp_invoke, prepare_checkout, confirm_transaction, and 1Password operations; authorize takes --browser-id and --page-url, passkey operations take --browser-id and optional --field, and restart_verification takes no parameters") + return nil, fmt.Errorf("--params is only supported for fill, webmcp_invoke, prepare_checkout, confirm_transaction, remove_payment_method, and 1Password operations; authorize takes --browser-id and --page-url, passkey operations take --browser-id and optional --field, and restart_verification and add_payment_method take no parameters") } return nil, nil } diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index d05c23fd..1e2359cf 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -31,10 +31,17 @@ var onePasswordRequestEntryFields = vaultOutputFields{ "parameters": vaultFieldsOf("website"), } var onePasswordRequestFields = vaultOutputFields{"version": nil, "goal": nil, "entries": onePasswordRequestEntryFields} + +// Enrollment actions omit capture_token, a bearer capability for embedded capture. var vaultMethodFields = vaultOutputFields{ - "id": nil, "provider": nil, "type": nil, "is_default": nil, + "id": nil, "provider": nil, "type": nil, "is_default": nil, "status": nil, "status_reason": nil, "expires_at": nil, "display": vaultFieldsOf("label brand last4"), "capabilities": {"single_use_card": vaultFieldsOf("eligible reasons")}, + "action": vaultFieldsOf("name url"), +} +var vaultMethodOperationFields = vaultOutputFields{ + "type": nil, "payment_method_id": nil, "status": nil, + "payment_method": vaultMethodFields, } var vaultLinkUserInfoFields = vaultOutputFields{ "id": nil, "email": nil, "name": nil, "first_name": nil, "last_name": nil, "phone": nil, @@ -79,6 +86,7 @@ var vaultItemFields = vaultOutputFields{ }, "state": { "provider": nil, "status": nil, "status_reason": nil, "user_id": nil, "domains": nil, "access_request_id": nil, + "payment_method_id": nil, "funding_status": nil, "access_request": { "id": nil, "state": nil, "goal": nil, "createdAt": nil, "has_autofill_token": nil, "granted_count": nil, "request": onePasswordRequestFields, "entries": onePasswordRequestEntryFields, @@ -92,7 +100,7 @@ var vaultItemFields = vaultOutputFields{ } var vaultEventFields = vaultOutputFields{ "id": nil, "name": nil, "created_at": nil, "browser_id": nil, - "data": vaultFieldsOf("reason operation status authorization_id preparation_id vault_session_id request_kind outcome_reason provider_status provider_code provider_request_id provider_payment_status provider_error_type provider_error_code provider_decline_code provider_error_param provider_http_status provider_response_bytes provider_latency_ms payment_intent_id payment_method_id checkout_session_id replay_attempted replay_delivered charged_amount_cents charged_currency charged_kind expected_cents actual_cents currency actual_currency intent_status amount_verified psp_error_code"), + "data": vaultFieldsOf("reason operation status authorization_id preparation_id vault_session_id request_kind outcome_reason provider_status provider_code provider_request_id provider_payment_status provider_error_type provider_error_code provider_decline_code provider_error_param provider_http_status provider_response_bytes provider_latency_ms payment_intent_id payment_method_id payment_method_id_present item_type provider checkout_session_id replay_attempted replay_delivered charged_amount_cents charged_currency charged_kind expected_cents actual_cents currency actual_currency intent_status amount_verified psp_error_code"), } // Vault output is a display-safe projection, not raw provider JSON. Keep presence @@ -366,7 +374,7 @@ func printVaultOperationHints(item *kernel.VaultItemUnion, vault, key, project s command += " --field ''" } } - if op.Type == "authorize" && item.Type == "card" && item.Spec.Provider == "link" && item.State.Status == "requested" { + if op.Type == "authorize" && isVaultPurchase(item) && item.Spec.Provider == "link" && item.State.Status == "requested" { command += " --browser-id '' --page-url ''" } pterm.Printf("invoke: %s -- %s %s %s\n", command, vaultShellArgument(vault), vaultShellArgument(key), vaultShellArgument(op.Type)) @@ -444,7 +452,7 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { if item.State.StatusReason != "" { rows = append(rows, []string{"status reason", item.State.StatusReason}) } - if item.Type == "card" { + if isVaultPurchase(item) { merchant := item.Spec.MerchantName if item.Spec.Provider == "agentcard" { merchant = item.Spec.Merchant @@ -466,6 +474,18 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { if item.Spec.Provider == "agentcard" && item.Spec.CheckoutOrigin != "" { rows = append(rows, []string{"checkout origin", item.Spec.CheckoutOrigin}) } + if item.Spec.Provider == "kernel" { + methodID := item.State.PaymentMethodID + if methodID == "" { + methodID = item.Spec.PaymentMethodID + } + if methodID != "" { + rows = append(rows, []string{"payment method id", methodID}) + } + if item.State.FundingStatus != "" { + rows = append(rows, []string{"funding status", string(item.State.FundingStatus)}) + } + } if item.Spec.Provider == "kernel" && item.Spec.MerchantURL != "" { rows = append(rows, []string{"merchant url", item.Spec.MerchantURL}) } @@ -573,8 +593,14 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction pterm.Info.Println("ready means required fields are populated, not that login succeeded. fill only when advertised; fill does not submit the form. passkey fields are never filled: use register_passkey, arm_passkey, and disarm_passkey only when advertised.") return } - if item.Type == "card" { + if isVaultPurchase(item) { if item.Spec.Provider == "kernel" { + switch item.State.FundingStatus { + case "unavailable": + pterm.Warning.Println("funding_status unavailable: the payment method is being removed or awaits recovery, so authorize, fill and confirm_transaction are refused until it recovers. status still reports what already happened to this purchase.") + case "removed": + pterm.Warning.Println("funding_status removed: the payment method was removed from its wallet, so this purchase can no longer be paid. create a new purchase with another active, eligible payment method; status still reports what already happened to this purchase.") + } switch item.State.Status { case "requested", "pending_authorization", "ready": pterm.Info.Println("Visa may require passkey approval through spend_approval; an alias-mode card may instead require fresh security-code collection. share the link privately and never open it for the cardholder; an unused link expires after 30 minutes. alias-mode cards normally expire five minutes after collection. ready means card details are available, not that the merchant charged the card; fill and submit before expires_at.") @@ -640,25 +666,98 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction func printVaultPaymentMethods(provider string, methods []kernel.VaultPaymentMethod) { if len(methods) == 0 { pterm.Info.Println("no payment methods returned") + if provider == "kernel" { + pterm.Info.Println("a connected kernel wallet may hold no cards; add one with wallets add-payment-method when add_payment_method is advertised.") + } return } - rows := pterm.TableData{{"payment method id", "provider", "type", "label", "brand", "last4", "default", "single-use eligible", "reasons"}} + rows := pterm.TableData{{"payment method id", "status", "provider", "type", "label", "brand", "last4", "default", "single-use eligible", "reasons"}} for _, m := range methods { capability := m.Capabilities.SingleUseCard eligible := "unknown" if capability.JSON.Eligible.Valid() { eligible = fmt.Sprint(capability.Eligible) } - rows = append(rows, []string{m.ID, m.Provider, m.Type, m.Display.Label, m.Display.Brand, m.Display.Last4, fmt.Sprint(m.IsDefault), eligible, strings.Join(capability.Reasons, ", ")}) + rows = append(rows, []string{m.ID, util.OrDash(string(m.Status)), m.Provider, m.Type, m.Display.Label, m.Display.Brand, m.Display.Last4, fmt.Sprint(m.IsDefault), eligible, strings.Join(capability.Reasons, ", ")}) } PrintTableNoPad(rows, true) + for _, m := range methods { + if m.StatusReason != "" { + pterm.Printf("payment method %s status reason: %s\n", m.ID, m.StatusReason) + } + printVaultEnrollmentAction(m) + } if provider == "kernel" { - pterm.Info.Println("kernel cards use the wallet key, not a payment method id. " + kernelEligibilityHelp) + pterm.Info.Println(kernelPaymentMethodHelp + ". " + kernelEligibilityHelp) } else { pterm.Info.Println("select an id explicitly in the card --spec json: Link uses payment_method_id; AgentCard uses card_id (or omit it for cardholder selection). capabilities are advisory; missing means unknown, not ineligible.") } } +// printVaultEnrollmentAction prints a pending method's card_enrollment url and returns it. +func printVaultEnrollmentAction(m kernel.VaultPaymentMethod) string { + if m.Action.Name != "card_enrollment" || m.Action.URL == "" { + return "" + } + label := "card enrollment url for " + m.ID + if !m.ExpiresAt.IsZero() { + label += " (expires " + util.FormatLocal(m.ExpiresAt) + ")" + } + pterm.Printf("%s:\n%s\n", label, m.Action.URL) + return m.Action.URL +} + +// printVaultPaymentMethodOperationResult prints an add_payment_method or remove_payment_method +// result and returns the enrollment url an added method returned, if display-safe. +func printVaultPaymentMethodOperationResult(response *kernel.VaultItemOperationResponseUnion, operation, output string) (string, error) { + invalid := fmt.Errorf("unexpected %s response; %s", operation, kernelVaultRetryGuidance(operation)) + if response == nil || response.Type != operation { + return "", invalid + } + raw, err := filterVaultJSON(json.RawMessage(response.RawJSON()), vaultMethodOperationFields) + if err != nil { + return "", invalid + } + var result struct { + PaymentMethodID string `json:"payment_method_id"` + Status string `json:"status"` + PaymentMethod kernel.VaultPaymentMethod `json:"payment_method"` + } + if json.Unmarshal(raw, &result) != nil { + return "", invalid + } + if operation == "remove_payment_method" { + if result.PaymentMethodID == "" || result.Status != "removed" { + return "", invalid + } + if output == "json" { + return "", printVaultJSON(raw) + } + pterm.Success.Println("removed payment method " + result.PaymentMethodID) + pterm.Info.Println(kernelRemovedPaymentMethodHelp) + return "", nil + } + method := result.PaymentMethod + if method.ID == "" { + return "", invalid + } + actionURL := "" + if method.Action.Name == "card_enrollment" { + actionURL = method.Action.URL + } + if output == "json" { + return actionURL, printVaultJSON(raw) + } + PrintTableNoPad(pterm.TableData{ + {"property", "value"}, {"payment method id", method.ID}, {"status", util.OrDash(string(method.Status))}, + }, true) + if printVaultEnrollmentAction(method) == "" { + pterm.Info.Println("no enrollment url returned; inspect the wallet with wallets payment-methods") + } + pterm.Info.Println(kernelEnrollmentHelp) + return actionURL, nil +} + func vaultExpandedString(value, raw string) string { if raw == "" || raw == "null" { return "—" diff --git a/cmd/vaults_output_test.go b/cmd/vaults_output_test.go index f09c2ffe..bca365f2 100644 --- a/cmd/vaults_output_test.go +++ b/cmd/vaults_output_test.go @@ -124,6 +124,45 @@ func TestVaultOutputKernelCardMerchantCountry(t *testing.T) { assert.JSONEq(t, `"US"`, string(decoded.Spec["merchant_country"])) } +func TestVaultOutputKernelPurchaseFunding(t *testing.T) { + for _, tc := range []struct { + state, want string + }{ + {`"status":"ready"`, ""}, + {`"status":"ready","payment_method_id":"card","funding_status":"available"`, ""}, + {`"status":"ready","payment_method_id":"pm-2","funding_status":"unavailable"`, "funding_status unavailable"}, + {`"status":"consumed","payment_method_id":"pm-2","funding_status":"removed"`, "funding_status removed"}, + } { + var item kernel.VaultItemUnion + require.NoError(t, json.Unmarshal([]byte(`{ + "id":"card-id","key":"order-1","type":"card", + "spec":{"provider":"kernel","wallet":"wallet-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example/checkout"}, + "state":{"provider":"kernel",`+tc.state+`}, + "available_operations":[],"available_expansions":[] + }`), &item)) + buf := capturePtermOutput(t) + require.NoError(t, printVaultItem(&item, "")) + if tc.want == "" { + assert.NotContains(t, buf.String(), "funding_status") + } else { + assert.Contains(t, buf.String(), tc.want) + assert.Contains(t, buf.String(), "pm-2") + } + out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) + var decoded struct { + State vaultJSON `json:"state"` + } + require.NoError(t, json.Unmarshal([]byte(out), &decoded)) + if strings.Contains(tc.state, "funding_status") { + assert.NotEmpty(t, decoded.State["funding_status"]) + assert.NotEmpty(t, decoded.State["payment_method_id"]) + } else { + assert.NotContains(t, decoded.State, "funding_status") + assert.NotContains(t, buf.String(), "payment method id") + } + } +} + func TestVaultOutputAgentCardAuthorizationIsNotPaymentSuccess(t *testing.T) { var item kernel.VaultItemUnion require.NoError(t, json.Unmarshal([]byte(`{ diff --git a/cmd/vaults_payment_help_test.go b/cmd/vaults_payment_help_test.go index 13e35d25..b6e72fa1 100644 --- a/cmd/vaults_payment_help_test.go +++ b/cmd/vaults_payment_help_test.go @@ -50,6 +50,31 @@ func TestVaultPaymentHelpProviderBoundaries(t *testing.T) { "kernel cards have no stored billing fields", "billing_* returns field_unavailable (400) before any write", "enter billing details on the merchant page", + "add_payment_method (kernel wallets, no parameters)", + "remove_payment_method (kernel wallets) requires --params or --spec-file with payment_method_id", + "409 purchase_unresolved", + "409 provider_revocation_pending", + "vaults:write in addition to vaults:use", + }, + }, + { + name: "wallets create", + path: []string{"wallets", "create"}, + want: []string{ + "creation returns an empty connected wallet that advertises add_payment_method", + "otherwise creation returns the default card's card_enrollment url", + "pending_enrollment (link open)", + }, + }, + { + name: "cards create", + path: []string{"cards", "create"}, + want: []string{ + "payment_method_id?: string; // from wallets payment-methods; omit for the default card, id card.", + "--type payment_request creates a Link or kernel purchase", + "kernel requests must name an active, eligible payment_method_id", + "AgentCard reusable cards remain card items", + "funding_status never changes the purchase's status", }, }, } { diff --git a/cmd/vaults_sdk_contract_test.go b/cmd/vaults_sdk_contract_test.go index cee81edf..63fc39f1 100644 --- a/cmd/vaults_sdk_contract_test.go +++ b/cmd/vaults_sdk_contract_test.go @@ -172,3 +172,43 @@ func TestCredentialFieldsRequireNames(t *testing.T) { assert.Contains(t, err.Error(), "name") assert.NotContains(t, err.Error(), "secret-echo") } + +func TestLinkPaymentRequestUpdateAndAuthorize(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + fixture := strings.Replace(requestedCardFixture, `"type":"card"`, `"type":"payment_request"`, 1) + spec := `{"wallet":"wallet-1","payment_method_id":"pm-1","amount":1500,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example","context":"Purchase description"}` + var bodies []string + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + var body json.RawMessage + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + bodies = append(bodies, r.Method+" "+string(body)) + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, fixture) + }) + _, human, err := executeVaultCommand(t, client, "vaults", "cards", "update", "checkout", "order-1", "--type", "payment_request", "--provider", "link", "--spec", spec) + require.NoError(t, err) + assert.Contains(t, human, "payment_request") + assert.Contains(t, human, "fill never submits payment") + _, _, err = executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "authorize", "--browser-id", "browser-1", "--page-url", "https://shop.example/checkout") + require.NoError(t, err) + require.Len(t, bodies, 2) + assert.True(t, strings.HasPrefix(bodies[0], "PATCH ")) + assert.JSONEq(t, `{"type":"payment_request","spec":{"provider":"link",`+spec[1:]+`}`, strings.TrimPrefix(bodies[0], "PATCH ")) + assert.JSONEq(t, `{"type":"authorize","browser_id":"browser-1","page_url":"https://shop.example/checkout"}`, strings.TrimPrefix(bodies[1], "POST ")) +} + +func TestVaultPurchaseEventsAreProjected(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `[{"id":"event-1","name":"purchase_created","created_at":"2026-10-28T00:00:00Z","data":{"item_type":"payment_request","provider":"kernel","payment_method_id_present":true,"provider_secret":"never-print"}},{"id":"event-2","name":"payment_method_removed","created_at":"2026-10-28T00:01:00Z","data":{"payment_method_id":"pm-2"}}]`) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "events", "checkout", "order-1", "-o", "json") + require.NoError(t, err) + for _, text := range []string{`"item_type": "payment_request"`, `"provider": "kernel"`, `"payment_method_id_present": true`, `"payment_method_id": "pm-2"`} { + assert.Contains(t, out, text) + } + assert.NotContains(t, out, "never-print") +} diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go index f2d640dd..cd0db165 100644 --- a/cmd/vaults_test.go +++ b/cmd/vaults_test.go @@ -52,7 +52,7 @@ func executeVaultCommand(t *testing.T, client kernel.Client, args ...string) (st } func TestVaultCommandConstruction(t *testing.T) { - for _, path := range []string{"create", "list", "get", "delete", "items list", "items get", "items delete", "items events", "wallets create", "wallets payment-methods", "cards create", "cards update", "items invoke", "items webmcp invoke"} { + for _, path := range []string{"create", "list", "get", "delete", "items list", "items get", "items delete", "items events", "wallets create", "wallets payment-methods", "wallets add-payment-method", "wallets remove-payment-method", "cards create", "cards update", "items invoke", "items webmcp invoke"} { t.Run(path, func(t *testing.T) { cmd, remaining, err := newVaultsCommand().Find(strings.Fields(path)) require.NoError(t, err) diff --git a/go.mod b/go.mod index 73f72506..27df82e2 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.131.0 + github.com/kernel/kernel-go-sdk v0.132.1-0.20261027142730-6b2ae5c1cb1d github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 2b7257a1..b2a3b336 100644 --- a/go.sum +++ b/go.sum @@ -68,6 +68,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kernel/kernel-go-sdk v0.131.0 h1:9aYi3O5N8g/cNwAIevmL+lOy8yy6daz5EXciCPipLco= github.com/kernel/kernel-go-sdk v0.131.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.132.1-0.20261027142730-6b2ae5c1cb1d h1:oibYTk5aL2bNedBcB/5Cgn5TMm3nTizkzUFTail67HE= +github.com/kernel/kernel-go-sdk v0.132.1-0.20261027142730-6b2ae5c1cb1d/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=