diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 2c962c3f..3ae635f2 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -88,9 +88,11 @@ otherwise, the api resolves the project from your credentials and its defaults. vault names, item keys, and project ownership are immutable. 1. create/select a vault, then create a provider wallet and follow any returned action. - for kernel, share the hosted card_enrollment url; for Shop Pay, share shop_pay_oauth. - a VGS wallet instead binds a required VGS provider configuration and connects without - storing cards or payment methods. card details and OAuth approval stay with their provider. + for kernel, share a hosted card_enrollment url with the cardholder, or use + add_payment_method --enrollment server when the card's issuer will post to card_ingest. + for Shop Pay, share shop_pay_oauth. a VGS wallet instead binds a required VGS provider + configuration and connects without storing cards or payment methods. card details and + OAuth approval stay with their provider. 2. inspect wallets payment-methods for providers that advertise it before creating a purchase. for Link, select a payment method id explicitly. VGS wallets have no payment methods. 3. attach the vault with browsers create --vault ; attachment is required for @@ -280,11 +282,14 @@ or CAPTURE. an APPROVED outcome may add order_id (1..255 characters) and order_u max 2048 characters); other outcomes may add failure_code (1..64 lowercase letters, digits, or underscores). payment_declined is only for DECLINED, which accepts no other failure code. unknown fields are rejected. never infer success; each purchase accepts one report. ` + kernelTransactionHelp + `. -add_payment_method takes no parameters and is valid only when advertised by a connected -KERNEL wallet. each call creates a separate pending enrollment with a single-use url that -expires after 30 minutes; share it only with the cardholder and never retry automatically. -a failed or expired enrollment does not affect other methods. remove_payment_method -requires {"payment_method_id":""}. it blocks new purchases, fills, and confirmations +add_payment_method is valid only when advertised by a connected KERNEL wallet. +--enrollment hosted is the default and returns a single-use card_enrollment url for the +cardholder that expires after 30 minutes. --enrollment server returns a single-use +card_ingest url for the card's issuer to post the card to from its backend; it expires after +5 minutes and returns 409 when server enrollment is not configured for the organization's +card backend. each call creates a separate pending enrollment. never log an action url or +retry automatically; a failed or expired enrollment does not affect other methods. +remove_payment_method requires {"payment_method_id":""}. it blocks new purchases, fills, and confirmations funded by that method without disconnecting the wallet or changing other methods. an unresolved purchase can block removal; an uncertain provider outcome leaves removal_pending and must be re-checked before an explicit retry. removal is not proof that a payment did @@ -360,6 +365,7 @@ JSON kernel vaults items invoke user-vault github 1pw_fill --params '{"browser_id":"","page_url":"https://github.com/login"}' kernel vaults items invoke user-vault github 1pw_fill --params '{"browser_id":"","page_url":"https://github.com/login","entry_id":"","target_id":""}' kernel vaults items invoke checkout cardholder add_payment_method + kernel vaults items invoke checkout cardholder add_payment_method --enrollment server kernel vaults items invoke checkout cardholder remove_payment_method --params '{"payment_method_id":""}' kernel vaults items invoke checkout wallet-1 update_access_token --spec-file ./link-access-token.json kernel vaults items invoke user-vault login register_passkey --browser-id --field passkey @@ -389,6 +395,10 @@ JSON if err != nil { return err } + enrollment, _ := cmd.Flags().GetString("enrollment") + if err := applyVaultAddPaymentMethodEnrollment(args[2], params, enrollment, cmd.Flags().Changed("enrollment")); err != nil { + return err + } passkey, err := parseVaultPasskeyOperationFlags(cmd, args[2]) if err != nil { return err @@ -411,6 +421,7 @@ JSON invoke.Flags().String("browser-id", "", "browser session id, not a reusable name; used by Link or Shop Pay authorize (with --page-url) and passkey operations") invoke.Flags().String("page-url", "", "Link or Shop Pay authorize only: exact current top-level checkout url open in --browser-id") invoke.Flags().String("field", "", "passkey field to arm, receive a registration, or disarm; arm/register require it when ambiguous, while disarm omits it to affect every field") + invoke.Flags().String("enrollment", "", "add_payment_method enrollment flow: hosted (default, cardholder link) or server (issuer backend ingest url)") addVaultJSONOutputFlag(invoke) items.AddCommand(itemList, itemGet, itemEvents, invoke, newVaultWebMCPCommand(), newVaultDeleteCommand(true)) diff --git a/cmd/vaults_confirm_transaction.go b/cmd/vaults_confirm_transaction.go index 4e07a702..902d9a70 100644 --- a/cmd/vaults_confirm_transaction.go +++ b/cmd/vaults_confirm_transaction.go @@ -69,11 +69,11 @@ func parseVaultConfirmTransactionParams(raw string) (*kernel.ConfirmTransactionV if rawOrderURL, ok := object["order_url"]; ok { var orderURL string if json.Unmarshal(rawOrderURL, &orderURL) != nil || len(orderURL) > 2048 { - return nil, fmt.Errorf("order_url must be an https URL of at most 2048 characters") + return nil, fmt.Errorf("order_url must be an https url of at most 2048 characters") } u, err := url.ParseRequestURI(orderURL) if err != nil || u.Scheme != "https" || u.Host == "" { - return nil, fmt.Errorf("order_url must be an https URL of at most 2048 characters") + return nil, fmt.Errorf("order_url must be an https url of at most 2048 characters") } params.OrderURL = kernel.Opt(orderURL) } diff --git a/cmd/vaults_confirm_transaction_test.go b/cmd/vaults_confirm_transaction_test.go index c22c10dc..a1848068 100644 --- a/cmd/vaults_confirm_transaction_test.go +++ b/cmd/vaults_confirm_transaction_test.go @@ -139,7 +139,7 @@ func TestVaultConfirmTransactionShopPayOutcomeDetails(t *testing.T) { `{"status":"ERROR","transaction_type":"PURCHASE","amount":1,"currency":"USD","occurred_at":"2026-09-01T12:00:00Z","failure_code":"UPPERCASE"}`: "lowercase letters", `{"status":"DECLINED","transaction_type":"PURCHASE","amount":1,"currency":"USD","occurred_at":"2026-09-01T12:00:00Z","failure_code":"site_error"}`: "accepts only failure_code payment_declined", `{"status":"ERROR","transaction_type":"PURCHASE","amount":1,"currency":"USD","occurred_at":"2026-09-01T12:00:00Z","failure_code":"payment_declined"}`: "requires status DECLINED", - `{"status":"APPROVED","transaction_type":"PURCHASE","amount":1,"currency":"USD","occurred_at":"2026-09-01T12:00:00Z","order_url":"http://shop.example/orders/1"}`: "https URL", + `{"status":"APPROVED","transaction_type":"PURCHASE","amount":1,"currency":"USD","occurred_at":"2026-09-01T12:00:00Z","order_url":"http://shop.example/orders/1"}`: "https url", `{"status":"APPROVED","transaction_type":"PURCHASE","amount":1,"currency":"USD","occurred_at":"2026-09-01T12:00:00Z","order_id":""}`: "non-empty string", } { _, err := parseVaultOperationParams("confirm_transaction", raw, true, false) diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index 9ed45702..8932487f 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -128,7 +128,7 @@ sensitive, and value. text, email, and password have form inputs. totp and passk not, so both KERNEL-hosted and customer-hosted collection forms omit them. custom collection web apps that encrypt in the browser may send encrypted_value (a compact jwe to the key from vaults get-encryption-key) instead of value; never both. -a passkey value is a JSON-encoded FIDO Credential Exchange Format object with unpadded +a passkey value is a json-encoded FIDO Credential Exchange Format object with unpadded base64url credentialId, a userHandle of at most 64 bytes, a PKCS#8 ECDSA P-256 private key, a lowercase rpId domain, and optional username and userDisplayName. other CXF members are ignored. alternatively, declare an optional empty passkey field and use diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index e5c87f39..c0b44478 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -18,14 +18,14 @@ const shopPayRecoveryHelp = "follow status_reason. Shop Pay issuance recovery ma const shopPayFlowHelp = `Shop Pay checkout (the item's available_operations descriptions are authoritative): 1. create a Shop Pay wallet with --provider shop_pay and Kernel-managed OAuth authorization, - share the returned shop_pay_oauth URL unchanged with the shopper, and wait for connected. + share the returned shop_pay_oauth url unchanged with the shopper, and wait for connected. 2. inspect wallets payment-methods. a card spec may select payment_method_id or omit it to use the shopper's preferred card. 3. create a card with amount, USD currency, merchant details, shopper-facing context, and totals containing exactly one positive total no greater than amount. 4. after explicit user approval, invoke authorize. at final checkout pass --browser-id and --page-url together so Shop receives that page as the merchant and fill is locked to its - origin; omit both to use merchant_url. share any approval URL unchanged and observe the item. + origin; omit both to use merchant_url. share any approval url unchanged and observe the item. 5. when fill is advertised, fill only the fields requested by the checkout and submit it. ready means a credential was issued, not that the merchant accepted payment. 6. report the observed merchant outcome once with confirm_transaction. Shop Pay accepts @@ -61,14 +61,17 @@ const vaultWalletSpecHelp = ` for --provider kernel, use --spec '{}'. if creation returns card_enrollment, share it with the cardholder; never enter card details in the cli. where the wallet advertises add_payment_method, creation instead returns an empty connected wallet and -each card is enrolled through that operation. use wallets get --wait 60 to observe +each card is enrolled through that operation. its default hosted flow returns a +card_enrollment link for the cardholder; --enrollment server returns a card_ingest url +for the card's issuer backend and is available only when the organization's card backend +is configured. use wallets get --wait 60 to observe connection, then wallets payment-methods to inspect card status and token eligibility. a connected wallet may have zero cards or cards that are still ineligible while network token enrollment runs. ` + kernelWalletHelp + `. ` + kernelEligibilityHelp + `. kernel wallets accept neither provider configuration flags nor --tokens-file. Shop Pay wallets also accept neither: they always use Kernel-managed OAuth. creation returns -shop_pay_oauth; share its URL unchanged with the shopper and observe with wallets get --wait 60. +shop_pay_oauth; share its url unchanged with the shopper and observe with wallets get --wait 60. for Link and AgentCard, omit config selection to preserve managed defaults. VGS has no managed default: select exactly one VGS configuration through --provider-config-id, --provider-config-name, or spec.provider_config. the binding is immutable. @@ -115,8 +118,10 @@ type AgentCardWalletSpec = { // cards stored with KERNEL-managed credentials. each is a wallet payment method. // where add_payment_method is advertised, creation returns an empty connected wallet; -// invoke add_payment_method once for each card enrollment. otherwise creation returns -// the default card's card_enrollment action and connects after that card is stored. +// invoke add_payment_method once for each card enrollment. hosted (the default) returns +// a card_enrollment action for the cardholder; server returns a card_ingest action for +// the card's issuer backend. otherwise creation returns the default card's +// card_enrollment action and connects after that card is stored. // KERNEL enrolls stored cards for agentic network tokens when supported; until then // payment-methods can report capabilities.single_use_card.eligible=false. // card numbers never reach KERNEL or the cli. no provider config or tokens. @@ -228,7 +233,7 @@ type VgsCardSpec = { amount: number; // integer minor units; 1..1000000 currency: string; // aud, brl, cad, chf, czk, dkk, eur, gbp, hkd, inr, jpy, krw, mxn, nok, nzd, pln, sek, sgd, usd, or zar merchant_name: string; - merchant_url: string; // https checkout URL; fill is restricted to this exact origin + merchant_url: string; // https checkout url; fill is restricted to this exact origin card_mode?: "full" | "cvc_only"; // default "full"; reads return it only for cvc_only, so absent means full card: { // required on create; --spec-file only; never returned pan_alias?: string; // persistent PAN alias; required with full, rejected with cvc_only @@ -261,7 +266,7 @@ type ShopPayCardSpec = { amount: number; // approval limit in minor units; 1..1000000 currency: string; // USD only merchant_name: string; // 1..255 characters - merchant_url: string; // merchant https URL; used when authorize omits checkout context + merchant_url: string; // merchant https url; used when authorize omits checkout context merchant_country?: string; // iso 3166-1 alpha-2 context: string; // 100..500 characters shown to the shopper line_items?: ShopPayLineItem[]; diff --git a/cmd/vaults_kernel_errors.go b/cmd/vaults_kernel_errors.go index 59058aaf..0372e2c7 100644 --- a/cmd/vaults_kernel_errors.go +++ b/cmd/vaults_kernel_errors.go @@ -154,7 +154,7 @@ const kernelEligibilityHelp = "eligible=false means do not authorize. for networ const kernelFundingStatusHelp = "funding_status is separate from purchase status. unavailable means the selected payment method is being removed or needs recovery, so authorize, fill, and confirmation are refused until it recovers. removed means the method is gone and this purchase cannot be paid; reconcile its prior outcome before creating a new purchase with another method. neither value rewrites what already happened to the purchase" -const kernelWalletHelp = "share card_enrollment only with the intended cardholder privately, never open it for them. where add_payment_method is advertised, use it to enroll each card; remove_payment_method removes one method without disconnecting the wallet. reconnect_required marks a legacy wallet awaiting an operator upgrade; reads preserve its state, and payments and deletion are refused until the upgrade. degraded: with user confirmation, delete the wallet only if the api permits it before creating a new wallet under a new key; reconcile unresolved child purchases first. connected may have zero cards and does not imply token eligibility; inspect wallets payment-methods" +const kernelWalletHelp = "share card_enrollment only with the intended cardholder privately, never open it for them. where add_payment_method is advertised, use it to enroll each card: hosted returns a 30-minute cardholder link, while --enrollment server returns a 5-minute card_ingest url for the card's issuer backend when configured. remove_payment_method removes one method without disconnecting the wallet. reconnect_required marks a legacy wallet awaiting an operator upgrade; reads preserve its state, and payments and deletion are refused until the upgrade. degraded: with user confirmation, delete the wallet only if the api permits it before creating a new wallet under a new key; reconcile unresolved child purchases first. connected may have zero cards and does not imply token eligibility; inspect wallets payment-methods" const kernelReplacementHelp = "after user confirmation, delete only if the api permits it and create one new purchase item under a new key; reconcile unresolved outcomes first" diff --git a/cmd/vaults_link.go b/cmd/vaults_link.go index a2bd59f0..e7c1d670 100644 --- a/cmd/vaults_link.go +++ b/cmd/vaults_link.go @@ -88,7 +88,7 @@ func parseVaultAuthorizeCheckout(cmd *cobra.Command, operation string) (*kernel. return nil, fmt.Errorf("--browser-id must be a non-empty browser session ID, not a name") } if _, err := url.Parse(pageURL); err != nil || !linkCheckoutPageURL.MatchString(pageURL) { - return nil, fmt.Errorf("--page-url must be the exact current top-level https checkout URL") + return nil, fmt.Errorf("--page-url must be the exact current top-level https checkout url") } return &kernel.AuthorizeVaultItemOperationRequestParam{ Type: kernel.AuthorizeVaultItemOperationRequestTypeAuthorize, diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go index 285f1698..8bd7b830 100644 --- a/cmd/vaults_operation_params.go +++ b/cmd/vaults_operation_params.go @@ -35,6 +35,27 @@ func vaultOperationTakesParams(operation string) bool { return operation == "fill" || operation == "webmcp_invoke" || operation == "prepare_checkout" || operation == "confirm_transaction" || operation == "remove_payment_method" || operation == "update_access_token" || (isOnePasswordOperation(operation) && operation != "1pw_recover") } +func applyVaultAddPaymentMethodEnrollment(operation string, params *vaultOperationParams, enrollment string, set bool) error { + if !set { + return nil + } + if operation != "add_payment_method" { + return fmt.Errorf("--enrollment is only supported for add_payment_method") + } + if params == nil || params.PaymentMethod == nil || params.PaymentMethod.OfAddPaymentMethod == nil { + return fmt.Errorf("add_payment_method parameters were not initialized") + } + switch enrollment { + case "hosted": + params.PaymentMethod.OfAddPaymentMethod.Enrollment = kernel.AddPaymentMethodVaultItemOperationRequestEnrollmentHosted + case "server": + params.PaymentMethod.OfAddPaymentMethod.Enrollment = kernel.AddPaymentMethodVaultItemOperationRequestEnrollmentServer + default: + return fmt.Errorf("--enrollment must be hosted or server") + } + return nil +} + type vaultFillParams struct { BrowserID string `json:"browser_id"` PageURL string `json:"page_url,omitempty"` @@ -50,7 +71,7 @@ type vaultFillField struct { // Reject duplicate and unknown keys without including payloads in diagnostics. func vaultParamsObject(raw, allowed string) (map[string]json.RawMessage, error) { - invalid := fmt.Errorf("operation parameters must contain JSON objects with only supported, non-duplicate properties") + invalid := fmt.Errorf("operation parameters must contain json objects with only supported, non-duplicate properties") dec := json.NewDecoder(strings.NewReader(raw)) token, err := dec.Token() if err != nil || token != json.Delim('{') { @@ -142,7 +163,7 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( } if operation == "add_payment_method" { if paramsSet { - return nil, fmt.Errorf("add_payment_method takes no parameters") + return nil, fmt.Errorf("add_payment_method uses --enrollment hosted|server and does not accept --params or --spec-file") } return &vaultOperationParams{PaymentMethod: &kernel.VaultItemPerformOperationParams{ OfAddPaymentMethod: &kernel.AddPaymentMethodVaultItemOperationRequestParam{Type: kernel.AddPaymentMethodVaultItemOperationRequestTypeAddPaymentMethod}, @@ -199,7 +220,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, remove_payment_method, update_access_token, and 1Password operations; authorize takes --browser-id and --page-url, passkey operations take --browser-id and optional --field, and add_payment_method and restart_verification take no parameters") + return nil, fmt.Errorf("--params is only supported for fill, webmcp_invoke, prepare_checkout, confirm_transaction, remove_payment_method, update_access_token, and 1Password operations; authorize takes --browser-id and --page-url, passkey operations take --browser-id and optional --field, add_payment_method takes --enrollment, and restart_verification takes no parameters") } return nil, nil } @@ -224,11 +245,11 @@ func parseVaultFillParams(raw string) (*vaultFillParams, error) { } if rawURL, present := object["page_url"]; present { if json.Unmarshal(rawURL, ¶ms.PageURL) != nil { - return nil, fmt.Errorf("page_url must be an absolute URL") + return nil, fmt.Errorf("page_url must be an absolute url") } u, err := url.ParseRequestURI(params.PageURL) if err != nil || u.Scheme == "" { - return nil, fmt.Errorf("page_url must be an absolute URL") + return nil, fmt.Errorf("page_url must be an absolute url") } } if timeout, ok := object["timeout_ms"]; ok { @@ -337,10 +358,10 @@ func parseOnePasswordOperationParams(operation, raw string) (*kernel.VaultItemPe } request := kernel.OnePasswordFillVaultItemOperationRequestParam{BrowserID: browserID, Type: kernel.OnePasswordFillVaultItemOperationRequestType1pwFill} if json.Unmarshal(object["page_url"], &request.PageURL) != nil { - return nil, fmt.Errorf("page_url must be the exact absolute URL of the open login page") + return nil, fmt.Errorf("page_url must be the exact absolute url of the open login page") } if u, err := url.ParseRequestURI(request.PageURL); err != nil || u.Scheme == "" { - return nil, fmt.Errorf("page_url must be the exact absolute URL of the open login page") + return nil, fmt.Errorf("page_url must be the exact absolute url of the open login page") } if value, ok := object["timeout_ms"]; ok { var timeout *int64 diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index bcecba60..b8fe981f 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -33,7 +33,7 @@ var onePasswordRequestEntryFields = vaultOutputFields{ var onePasswordRequestFields = vaultOutputFields{"version": nil, "goal": nil, "entries": onePasswordRequestEntryFields} var vaultMethodFields = vaultOutputFields{ "id": nil, "provider": nil, "type": nil, "is_default": nil, - "status": nil, "status_reason": nil, "expires_at": nil, + "status": nil, "status_reason": nil, "expires_at": nil, "issuer_reference": nil, "valid_until": nil, "action": vaultFieldsOf("name url"), "display": vaultFieldsOf("label brand last4"), "capabilities": {"single_use_card": vaultFieldsOf("eligible reasons")}, @@ -745,7 +745,11 @@ func printVaultPaymentMethodOperationResult(response *kernel.VaultItemOperationR result := response.AsAddPaymentMethodVaultItemOperationResult() pterm.Success.Println("Started payment method enrollment") printVaultPaymentMethods("kernel", []kernel.VaultPaymentMethod{result.PaymentMethod}) - pterm.Info.Println("share the single-use card_enrollment url only with the intended cardholder; it expires after 30 minutes. every call creates a separate link, so never retry automatically. observe completion with wallets payment-methods; an expired or failed enrollment does not affect other methods.") + if result.PaymentMethod.Action.Name == "card_ingest" { + pterm.Info.Println("have the card's issuer post the card to the single-use card_ingest url from its backend; never open it in a browser or share it with the cardholder. it expires after 5 minutes. every call creates a separate url, so never retry automatically. observe completion with wallets payment-methods; an expired or failed enrollment does not affect other methods.") + } else { + pterm.Info.Println("share the single-use card_enrollment url only with the intended cardholder; it expires after 30 minutes. every call creates a separate link, so never retry automatically. observe completion with wallets payment-methods; an expired or failed enrollment does not affect other methods.") + } return nil } result := response.AsRemovePaymentMethodVaultItemOperationResult() @@ -759,7 +763,19 @@ func printVaultPaymentMethods(provider string, methods []kernel.VaultPaymentMeth pterm.Info.Println("no payment methods returned") return } - rows := pterm.TableData{{"payment method id", "provider", "type", "label", "brand", "last4", "default", "status", "single-use eligible", "reasons", "status reason", "expires at"}} + showIssuerReference, showValidUntil := false, false + for _, method := range methods { + showIssuerReference = showIssuerReference || method.JSON.IssuerReference.Valid() + showValidUntil = showValidUntil || method.JSON.ValidUntil.Valid() + } + headers := []string{"payment method id", "provider", "type", "label", "brand", "last4", "default", "status", "single-use eligible", "reasons", "status reason", "expires at"} + if showIssuerReference { + headers = append(headers, "issuer reference") + } + if showValidUntil { + headers = append(headers, "valid until") + } + rows := pterm.TableData{headers} for _, m := range methods { capability := m.Capabilities.SingleUseCard eligible := "unknown" @@ -770,7 +786,18 @@ func printVaultPaymentMethods(provider string, methods []kernel.VaultPaymentMeth if m.JSON.ExpiresAt.Valid() && !m.ExpiresAt.IsZero() { expiresAt = util.FormatLocal(m.ExpiresAt) } - rows = append(rows, []string{m.ID, m.Provider, m.Type, m.Display.Label, m.Display.Brand, m.Display.Last4, fmt.Sprint(m.IsDefault), string(m.Status), eligible, strings.Join(capability.Reasons, ", "), m.StatusReason, expiresAt}) + row := []string{m.ID, m.Provider, m.Type, m.Display.Label, m.Display.Brand, m.Display.Last4, fmt.Sprint(m.IsDefault), string(m.Status), eligible, strings.Join(capability.Reasons, ", "), m.StatusReason, expiresAt} + if showIssuerReference { + row = append(row, m.IssuerReference) + } + if showValidUntil { + validUntil := "" + if m.JSON.ValidUntil.Valid() && !m.ValidUntil.IsZero() { + validUntil = util.FormatLocal(m.ValidUntil) + } + row = append(row, validUntil) + } + rows = append(rows, row) } PrintTableNoPad(rows, true) for _, method := range methods { @@ -780,7 +807,7 @@ func printVaultPaymentMethods(provider string, methods []kernel.VaultPaymentMeth } if provider == "kernel" { pterm.Info.Println("select payment_method_id explicitly for payment_request items and for card items when the wallet has no default card. a legacy default card may be selected by omitting it only from a card spec. " + kernelEligibilityHelp) - pterm.Info.Println("pending_enrollment needs the returned cardholder action. removal_pending means retry remove_payment_method later; recovery_required handling depends on status_reason.") + pterm.Info.Println("pending_enrollment needs the returned action: card_enrollment is a hosted link for the cardholder, while card_ingest is for the card's issuer backend. removal_pending means retry remove_payment_method later; recovery_required handling depends on status_reason.") } else { pterm.Info.Println("select an id explicitly in the purchase --spec json: Link uses payment_method_id; AgentCard cards use card_id (or omit it for cardholder selection). capabilities are advisory; missing means unknown, not ineligible.") } diff --git a/cmd/vaults_payment_method_operations_test.go b/cmd/vaults_payment_method_operations_test.go index 118498f1..b2bc400d 100644 --- a/cmd/vaults_payment_method_operations_test.go +++ b/cmd/vaults_payment_method_operations_test.go @@ -33,7 +33,11 @@ func TestVaultAddPaymentMethodOperation(t *testing.T) { posts++ body, err := io.ReadAll(r.Body) require.NoError(t, err) - assert.JSONEq(t, `{"type":"add_payment_method"}`, string(body)) + expected := `{"type":"add_payment_method"}` + if posts == 2 { + expected = `{"type":"add_payment_method","enrollment":"hosted"}` + } + assert.JSONEq(t, expected, string(body)) _, _ = io.WriteString(w, `{ "type":"add_payment_method", "payment_method":{ @@ -56,7 +60,7 @@ func TestVaultAddPaymentMethodOperation(t *testing.T) { assert.NotContains(t, out, "secret-capture") assert.NotContains(t, out, "secret-provider") - _, text, err = executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "cardholder", "add_payment_method") + _, text, err = executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "cardholder", "add_payment_method", "--enrollment", "hosted") require.NoError(t, err) for _, want := range []string{"Started payment method enrollment", "card-2", "pending_enrollment", "card_enrollment url", "single-use", "expires after 30 minutes", "separate link"} { assert.Contains(t, text, want) @@ -65,6 +69,46 @@ func TestVaultAddPaymentMethodOperation(t *testing.T) { assert.Equal(t, 2, posts) } +func TestVaultAddPaymentMethodServerEnrollment(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + posts := 0 + 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, kernelWalletPaymentOperationsFixture) + return + } + posts++ + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"add_payment_method","enrollment":"server"}`, string(body)) + _, _ = io.WriteString(w, `{ + "type":"add_payment_method", + "payment_method":{ + "id":"card-3","provider":"kernel","type":"card","is_default":false, + "status":"pending_enrollment","status_reason":"awaiting_issuer", + "expires_at":"2026-09-01T12:05:00Z", + "display":{},"capabilities":{}, + "action":{"name":"card_ingest","url":"https://cards.example.com/vault/card-ingest/card-3"} + } + }`) + }) + out, text, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "cardholder", "add_payment_method", "--enrollment", "server", "-o", "json") + require.NoError(t, err) + assert.Equal(t, 1, posts) + assert.Empty(t, text) + assert.Contains(t, out, `"name": "card_ingest"`) + assert.Contains(t, out, "https://cards.example.com/vault/card-ingest/card-3") + + _, text, err = executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "cardholder", "add_payment_method", "--enrollment", "server") + require.NoError(t, err) + assert.Equal(t, 2, posts) + for _, want := range []string{"card_ingest url", "issuer", "backend", "5 minutes", "never open", "separate url"} { + assert.Contains(t, strings.ToLower(text), want) + } + assert.NotContains(t, strings.ToLower(text), "share the single-use card_enrollment") +} + func TestVaultRemovePaymentMethodOperation(t *testing.T) { t.Setenv("KERNEL_PROJECT", "") posts := 0 @@ -96,7 +140,7 @@ func TestVaultPaymentMethodOperationValidation(t *testing.T) { params string want string }{ - {"add_payment_method", `{}`, "takes no parameters"}, + {"add_payment_method", `{}`, "does not accept --params"}, {"remove_payment_method", "", "requires --params"}, {"remove_payment_method", `{}`, "payment_method_id"}, {"remove_payment_method", `{"payment_method_id":""}`, "payment_method_id"}, @@ -109,6 +153,16 @@ func TestVaultPaymentMethodOperationValidation(t *testing.T) { _, _, err := executeVaultCommand(t, client, args...) require.ErrorContains(t, err, tc.want) } + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"vaults", "items", "invoke", "checkout", "cardholder", "add_payment_method", "--enrollment", "invalid"}, "--enrollment must be hosted or server"}, + {[]string{"vaults", "items", "invoke", "checkout", "cardholder", "remove_payment_method", "--enrollment", "server", "--params", `{"payment_method_id":"card-1"}`}, "only supported for add_payment_method"}, + } { + _, _, err := executeVaultCommand(t, client, tc.args...) + require.ErrorContains(t, err, tc.want) + } assert.Zero(t, calls) } @@ -120,6 +174,11 @@ func TestVaultPaymentMethodsExposeLifecycleWithoutCaptureToken(t *testing.T) { "expires_at":"2026-09-01T12:30:00Z", "display":{"brand":"visa","last4":"1234"},"capabilities":{}, "action":{"name":"card_enrollment","url":"https://vault.example/enroll?state=opaque","capture_token":"secret-capture"} + },{ + "id":"card-3","provider":"kernel","type":"card","is_default":false, + "status":"active","status_reason":"","issuer_reference":"issuer-ref-3", + "valid_until":"2026-09-02T12:00:00Z", + "display":{"brand":"visa","last4":"5678"},"capabilities":{"single_use_card":{"eligible":true,"reasons":[]}} }]}}` client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "payment_methods", r.URL.Query().Get("expand")) @@ -128,14 +187,14 @@ func TestVaultPaymentMethodsExposeLifecycleWithoutCaptureToken(t *testing.T) { }) out, _, err := executeVaultCommand(t, client, "vaults", "wallets", "payment-methods", "checkout", "cardholder", "-o", "json") require.NoError(t, err) - for _, want := range []string{`"status": "pending_enrollment"`, `"status_reason": "awaiting_cardholder"`, `"expires_at"`, `"name": "card_enrollment"`, "https://vault.example/enroll?state=opaque"} { + for _, want := range []string{`"status": "pending_enrollment"`, `"status_reason": "awaiting_cardholder"`, `"expires_at"`, `"name": "card_enrollment"`, "https://vault.example/enroll?state=opaque", `"issuer_reference": "issuer-ref-3"`, `"valid_until": "2026-09-02T12:00:00Z"`} { assert.Contains(t, out, want) } assert.NotContains(t, out, "secret-capture") _, text, err := executeVaultCommand(t, client, "vaults", "wallets", "payment-methods", "checkout", "cardholder") require.NoError(t, err) - for _, want := range []string{"pending_enrollment", "awaiting_cardholder", "card_enrollment url", "payment_method_id explicitly", "removal_pending"} { + for _, want := range []string{"pending_enrollment", "awaiting_cardholder", "card_enrollment url", "issuer-ref-3", "valid until", "payment_method_id explicitly", "removal_pending", "card_ingest"} { assert.Contains(t, text, want) } assert.NotContains(t, text, "secret-capture") @@ -145,10 +204,12 @@ func TestVaultPaymentMethodOperationHelp(t *testing.T) { invoke, _, err := newVaultsCommand().Find([]string{"items", "invoke"}) require.NoError(t, err) flat := strings.Join(strings.Fields(invoke.Long), " ") - for _, want := range []string{"add_payment_method takes no parameters", "remove_payment_method requires", "payment_method_id", "expires after 30 minutes", "separate pending enrollment", "without disconnecting the wallet", "removal_pending", "not proof that a payment did not occur", "vaults:write"} { + for _, want := range []string{"add_payment_method is valid", "--enrollment hosted", "--enrollment server", "card_ingest", "5 minutes", "remove_payment_method requires", "payment_method_id", "separate pending enrollment", "without disconnecting the wallet", "removal_pending", "not proof that a payment did not occur", "vaults:write"} { assert.Contains(t, flat, want) } - assert.Contains(t, invoke.Example, "add_payment_method") + assert.Contains(t, invoke.Example, "add_payment_method --enrollment server") assert.Contains(t, invoke.Example, "remove_payment_method") assert.Contains(t, invoke.Flags().Lookup("params").Usage, "remove_payment_method") + assert.Contains(t, invoke.Flags().Lookup("enrollment").Usage, "hosted") + assert.Contains(t, invoke.Flags().Lookup("enrollment").Usage, "server") } diff --git a/cmd/vaults_webmcp.go b/cmd/vaults_webmcp.go index cd0d438c..7253bc84 100644 --- a/cmd/vaults_webmcp.go +++ b/cmd/vaults_webmcp.go @@ -110,7 +110,7 @@ func parseVaultWebMCPParams(raw string) (*kernel.WebmcpInvokeVaultItemOperationR // Members stay json.RawMessage: the SDK encodes json.Number as a string, which would change // numeric arguments. func decodeVaultWebMCPInput(raw []byte) (map[string]any, error) { - invalid := fmt.Errorf("input must be a JSON object of public tool arguments (maximum 64 KiB)") + invalid := fmt.Errorf("input must be a json object of public tool arguments (maximum 64 KiB)") dec := json.NewDecoder(bytes.NewReader(raw)) var members map[string]json.RawMessage if dec.Decode(&members) != nil || members == nil { @@ -142,7 +142,7 @@ func validateVaultWebMCPParams(params *kernel.WebmcpInvokeVaultItemOperationRequ if params.TimeoutSec.Valid() && (params.TimeoutSec.Value < 1 || params.TimeoutSec.Value > 120) { return fmt.Errorf("timeout_sec must be an integer between 1 and 120") } - invalidInput := fmt.Errorf("input must be a JSON object of public tool arguments (maximum 64 KiB)") + invalidInput := fmt.Errorf("input must be a json object of public tool arguments (maximum 64 KiB)") if params.Input == nil { return invalidInput } @@ -175,7 +175,7 @@ func validateVaultWebMCPParams(params *kernel.WebmcpInvokeVaultItemOperationRequ return fmt.Errorf("%s format must be MM/YY or MM/YYYY", binding(i)) } if !vaultWebMCPNullSlot(input, b.InputPath) { - return fmt.Errorf("%s input_path must be an RFC 6901 JSON Pointer to an existing null value in input", binding(i)) + return fmt.Errorf("%s input_path must be an RFC 6901 json pointer to an existing null value in input", binding(i)) } } return nil @@ -288,7 +288,7 @@ Discover the tool first with kernel browsers webmcp list -o json an tool_ref and exact source.page_url (fragment omitted). The vault must already be attached to the browser. --input holds only public tool arguments; put null at each slot a vault field fills, and never include vault values. Each --bind maps one vault field to an -RFC 6901 JSON Pointer to an existing null value in input (no root or append paths). +RFC 6901 json pointer to an existing null value in input (no root or append paths). Each field and path may be bound once. Use := for card expiration (MM/YY or MM/YYYY); credential fields take no format. TOTP fields supply a fresh code, never the seed. --timeout-sec is 1-120 (API default 15). @@ -314,8 +314,8 @@ did. API rejections (400/403/404/409) mean the tool was not invoked by that requ invoke.Flags().String("browser-id", "", "Browser session ID the vault is attached to, not a name (required)") invoke.Flags().String("tool-ref", "", "Opaque tool_ref from browsers webmcp list (required)") invoke.Flags().String("page-url", "", "Exact source.page_url from browsers webmcp list, without fragment (required)") - invoke.Flags().String("input", "", "Public tool input JSON object with null slots for vault fields") - invoke.Flags().String("input-file", "", "Path to the input JSON object (use '-' for stdin)") + invoke.Flags().String("input", "", "Public tool input json object with null slots for vault fields") + invoke.Flags().String("input-file", "", "Path to the input json object (use '-' for stdin)") invoke.Flags().StringArray("bind", nil, "Vault field binding = or :=; repeatable (required)") invoke.Flags().Int64("timeout-sec", 0, "Tool invocation timeout in seconds, 1-120 (API default 15)") for _, name := range []string{"browser-id", "tool-ref", "page-url", "bind"} { diff --git a/go.mod b/go.mod index 2d7ca002..ff9e9dc7 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.142.0 + github.com/kernel/kernel-go-sdk v0.144.0 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 bee25dc5..22de7dcc 100644 --- a/go.sum +++ b/go.sum @@ -68,8 +68,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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.142.0 h1:1PC5SFDIWCjRDhD+j8RAdt8L9svJVKu9C/BDitDAfTg= -github.com/kernel/kernel-go-sdk v0.142.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U= +github.com/kernel/kernel-go-sdk v0.144.0 h1:KxburLFDs4Dlm/tvX8xFLPZz7DJRAQpy6WUrFRGnDO0= +github.com/kernel/kernel-go-sdk v0.144.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U= 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=