From 55056fd8951aa6483fd94a2435239337e62352cf Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:27:23 +0000 Subject: [PATCH 1/7] Add Link payment token commands --- cmd/vaults.go | 35 +++++-- cmd/vaults_commands.go | 98 +++++++++++++------ cmd/vaults_fill.go | 25 +++-- cmd/vaults_fill_test.go | 57 +++++------ cmd/vaults_help.go | 32 +++++- cmd/vaults_invoke_test.go | 149 +++++++++++----------------- cmd/vaults_operation_params.go | 14 +-- cmd/vaults_output.go | 43 ++++++++- cmd/vaults_output_test.go | 17 +++- cmd/vaults_policy_test.go | 6 +- cmd/vaults_secrets.go | 36 ++++++- cmd/vaults_spec_test.go | 73 ++++++++++++-- cmd/vaults_test.go | 161 +++++++++++++++---------------- cmd/vaults_wallet_config_test.go | 23 +---- cmd/vaults_wallet_spec_test.go | 2 +- 15 files changed, 463 insertions(+), 308 deletions(-) diff --git a/cmd/vaults.go b/cmd/vaults.go index 20fb1cf4..97e30765 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -188,20 +188,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 { - 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)) - } else { - item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCard: &kernel.VaultItemUpsertParamsBodyCard{Spec: spec}}, option.WithMaxRetries(0)) - } +func (c VaultsCmd) SaveCard(ctx context.Context, vault, key string, spec kernel.CardVaultItemSpecUnionParam, output string) error { + item, err := c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCard: &kernel.VaultItemUpsertParamsBodyCard{Spec: spec}}, option.WithMaxRetries(0)) if err != nil { return util.CleanedUpSdkError{Err: err} } return c.showItem(item, output, false) } +func (c VaultsCmd) CreatePaymentToken(ctx context.Context, vault, key string, spec map[string]json.RawMessage, output string) error { + body, err := json.Marshal(map[string]any{"type": "payment_token", "spec": spec}) + if err != nil { + return err + } + var item kernel.VaultItemUnion + _, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault}, option.WithRequestBody("application/json", body), option.WithResponseBodyInto(&item), option.WithMaxRetries(0)) + if err != nil { + return vaultPaymentTokenError(err) + } + return c.showItem(&item, output, false) +} + func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, params *vaultOperationParams, output string, open bool) error { if strings.TrimSpace(operation) == "" { return fmt.Errorf("operation must not be empty") @@ -243,6 +250,15 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par return fmt.Errorf("operation %q is not advertised in available_operations; inspect the item", operation) } if operation == "fill" { + if item.Type == "payment_token" { + if params.Fill.PageURL == "" || len(params.Fill.Fields) != 0 { + return fmt.Errorf("payment-token fill requires page_url and must omit fields") + } + } else if len(params.Fill.Fields) == 0 { + return fmt.Errorf("credential and card fill requires 1-32 field bindings") + } else if item.Type == "card" && params.Fill.PageURL == "" { + return fmt.Errorf("card fill requires page_url") + } return c.fill(ctx, vault, key, params.Fill, output) } request := kernel.VaultItemPerformOperationParams{IDOrName: vault} @@ -254,8 +270,7 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par } else if operation == "collect" { request.OfCollect = &kernel.CollectVaultItemOperationRequestParam{Type: "collect"} } else { - // Preserve support for other advertised parameterless operations. - request.OfAuthorize = &kernel.AuthorizeVaultItemOperationRequestParam{Type: kernel.AuthorizeVaultItemOperationRequestType(operation)} + return fmt.Errorf("unsupported vault item operation %q", operation) } response, err := c.vaults.Items.PerformOperation(ctx, key, request, option.WithMaxRetries(0)) if err != nil { diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 7be9f2d4..1920a305 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -73,14 +73,13 @@ 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 its returned action. -2. For Link, list wallet payment methods and select an ID explicitly. -3. Create a card request with --provider and --spec JSON. -4. Inspect items get, then use items invoke only when advertised. - Follow the operation description and any returned provider action. -5. Attach the vault with browsers create --vault . For ready Link cards, - use advertised fill with --params to bind checkout fields. Returned non-secret - aliases are an alternative for explicitly chosen egress-substitution integrations, - not a fallback after fill. Inspect items get/events for payment outcomes. +2. For Link, list wallet payment methods and select an ID explicitly. Create a browser + with --vault , navigate to final checkout, and gather final spend details. +3. Try payment-tokens create with that browser ID and exact page URL. Only + lpt_not_supported means create a card instead. Payment credential creation starts human approval. +4. Share the returned approval URL and retrieve the item until fill is advertised. +5. Invoke fill with its browser/page parameters. Fill never submits payment; inspect the + checkout and submit separately when ready. Inspect items get/events for the outcome. Permitted checkout domains are provider-assigned and displayed when returned; there is no domain-setting API. @@ -151,10 +150,11 @@ JSON output preserves returned public fields but omits unknown/opaque provider d invoke := &cobra.Command{Use: "invoke ", Short: "Invoke an operation advertised by an item", Args: cobra.ExactArgs(3), PreRunE: vaultPreRun, Long: `Retrieve the item and invoke only an operation listed in available_operations. collect returns a time-scoped URL for the full credential form without clearing values. -authorize sends {"type":"authorize"} for payment authorization. +Payment credential creation starts approval; there is no separate authorize operation. Read the operation description and follow any approval requirements before invoking. -fill requires --params JSON or --spec-file with browser_id (session ID, not name) -and 1-32 ordered fields (field, selector). Do not include type, values, or frame IDs. +fill requires --params JSON or --spec-file with browser_id (session ID, not name). +Credentials and cards require 1-32 ordered fields (field, selector). Payment tokens require +an exact page_url and must omit fields. Do not include type, values, or frame IDs. The vault must already be attached to the browser. page_url selects an existing page; fill never navigates. Credentials use declared field names, must omit format, and may omit page_url only when the API can resolve a unique page. TOTP codes stay server-generated. @@ -175,7 +175,7 @@ origin of the top-level merchant page), and checkout.environment (production or Use only when advertised for an AgentCard card. Keep the returned approval page open, poll until ready_to_submit, then submit native Pay before preparation.expires_at. Preparations are single-use, including after failure or expiry; never retry automatically. -collect/authorize/prepare_checkout may use --open. Fill returns value-free per-field outcomes; +collect/prepare_checkout may use --open. Fill returns value-free per-field outcomes; completed exits 0, failed/unknown exit nonzero with valid JSON retained on stdout in -o json.`, Example: ` kernel vaults items invoke user-vault login collect kernel vaults items invoke user-vault login fill --spec-file - <<'JSON' @@ -246,9 +246,11 @@ JSON addVaultJSONOutputFlag(methods) wallets.AddCommand(walletCreate, methods) - cards := &cobra.Command{Use: "cards", Short: "Configure card requests"} - cards.AddCommand(newVaultCardCommand(false), newVaultCardCommand(true)) - cmd.AddCommand(items, wallets, cards, newVaultCredentialsCommand()) + cards := &cobra.Command{Use: "cards", Short: "Create immutable card requests at final checkout"} + cards.AddCommand(newVaultCardCommand()) + paymentTokens := &cobra.Command{Use: "payment-tokens", Short: "Create merchant-bound Link payment tokens at final checkout"} + paymentTokens.AddCommand(newVaultPaymentTokenCommand()) + cmd.AddCommand(items, wallets, cards, paymentTokens, newVaultCredentialsCommand()) return cmd } @@ -270,22 +272,10 @@ func newVaultDeleteCommand(item bool) *cobra.Command { return cmd } -func newVaultCardCommand(update bool) *cobra.Command { - use, short := "create", "Create a card request without authorizing it" - if update { - use, short = "update", "Update a card spec when the API permits configuration" - } - cmd := &cobra.Command{Use: use + " --provider --spec ''", Short: short, Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, - Long: short + `. Neither create nor update authorizes a Link card. -Requested cards accept a replacement spec. Pending issuance updates preserve omitted -optional fields; explicit empty lists clear them. The API restricts fields after -authorization starts; wallet/provider bindings cannot change. An uncertain update -enters recovery_required and must not be retried. Checkout cards can be edited -between authorizations. Identical creates return existing state without resetting it. -Never reconfigure the same item to retry a failed, timed-out, rejected, or indeterminate payment. -A recovery item that permits abandonment must be deleted after explicit user confirmation before creating a replacement. -` + vaultSpecHelp + vaultCardSpecHelp, - Example: " kernel vaults cards " + use + ` checkout order-1 \ +func newVaultCardCommand() *cobra.Command { + cmd := &cobra.Command{Use: "create --provider --spec ''", Short: "Create an immutable card request and start approval", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: "Create a card after reaching final checkout. Link creation starts human approval; share the returned URL and retrieve the item until fill appears.\n" + vaultSpecHelp + vaultCardSpecHelp + vaultLinkPurchaseTypesHelp, + Example: " kernel vaults cards create" + ` checkout order-1 \ --provider agentcard --spec '{ "wallet": "wallet-1", "merchant": "Example Shop", @@ -297,13 +287,57 @@ A recovery item that permits abandonment must be deleted after explicit user con if err != nil { return err } - 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], param.Override[kernel.CardVaultItemSpecUnionParam](spec), vaultOutput(cmd)) }} addVaultSpecFlags(cmd) addVaultJSONOutputFlag(cmd) return cmd } +func newVaultPaymentTokenCommand() *cobra.Command { + cmd := &cobra.Command{Use: "create --spec ''", Short: "Create an immutable Link payment token and start approval", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: "Create a payment token from the active final checkout. Kernel discovers Link support and the Stripe merchant binding from the browser.\n" + vaultSpecHelp + vaultPaymentTokenSpecHelp + vaultLinkPurchaseTypesHelp, + Example: ` kernel vaults payment-tokens create checkout order-1 --spec '{ + "wallet":"wallet-1", + "browser_id":"browser-session-id", + "page_url":"https://shop.example/checkout", + "payment_method_id":"pm-1", + "amount":1234, + "currency":"usd", + "context":"Final checkout for one item totaling USD 12.34. This is a new purchase and not a retry of an uncertain payment." + }'`, + RunE: func(cmd *cobra.Command, args []string) error { + spec, err := vaultPaymentTokenSpecFromFlags(cmd) + if err != nil { + return err + } + return getVaultsHandler(cmd).CreatePaymentToken(cmd.Context(), args[0], args[1], spec, vaultOutput(cmd)) + }} + cmd.Flags().String("spec", "", "Raw Link payment-token specification object (required)") + _ = cmd.MarkFlagRequired("spec") + addVaultJSONOutputFlag(cmd) + return cmd +} + +func vaultPaymentTokenSpecFromFlags(cmd *cobra.Command) (map[string]json.RawMessage, error) { + raw, _ := cmd.Flags().GetString("spec") + var spec map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &spec); err != nil || spec == nil { + return nil, fmt.Errorf("--spec must be a JSON object") + } + if _, exists := spec["provider"]; exists { + return nil, fmt.Errorf("payment-token provider is Link; omit spec.provider") + } + if _, exists := spec["merchant_account_id"]; exists { + return nil, fmt.Errorf("omit merchant_account_id; Kernel discovers it from the checkout") + } + if vaultSpecHasSecrets(json.RawMessage(raw)) { + return nil, fmt.Errorf("--spec must not contain credentials or tokens") + } + spec["provider"] = json.RawMessage(`"link"`) + return spec, nil +} + func addVaultSpecFlags(cmd *cobra.Command) { cmd.Flags().String("provider", "", "Provider: link or agentcard (required)") cmd.Flags().String("spec", "", "Raw JSON specification object (required); see types and examples above") diff --git a/cmd/vaults_fill.go b/cmd/vaults_fill.go index b2d787d8..7ce81e16 100644 --- a/cmd/vaults_fill.go +++ b/cmd/vaults_fill.go @@ -13,9 +13,10 @@ import ( ) type vaultFillResult struct { - Type string `json:"type"` - Status string `json:"status"` - Fields []vaultFillFieldResult `json:"fields"` + Type string `json:"type"` + Status string `json:"status"` + Instruction string `json:"instruction,omitempty"` + Fields []vaultFillFieldResult `json:"fields"` } type vaultFillFieldResult struct { @@ -25,7 +26,7 @@ type vaultFillFieldResult struct { } var vaultFillResultFields = vaultOutputFields{ - "type": nil, "status": nil, + "type": nil, "status": nil, "instruction": nil, "fields": vaultFieldsOf("index status error_code"), } @@ -109,13 +110,19 @@ func (c VaultsCmd) fill(ctx context.Context, vault, key string, params *vaultFil } } else { pterm.Printf("Fill: %s\n", result.Status) - rows := pterm.TableData{{"Field index", "Status", "Error code"}} - for _, field := range result.Fields { - rows = append(rows, []string{strconv.Itoa(*field.Index), field.Status, field.ErrorCode}) + if len(result.Fields) > 0 { + rows := pterm.TableData{{"Field index", "Status", "Error code"}} + for _, field := range result.Fields { + rows = append(rows, []string{strconv.Itoa(*field.Index), field.Status, field.ErrorCode}) + } + PrintTableNoPad(rows, true) } - PrintTableNoPad(rows, true) if result.Status == "completed" { - pterm.Println("Fields filled; this does not confirm website acceptance or form submission.") + if result.Instruction != "" { + pterm.Println(result.Instruction) + } else { + pterm.Println("Fields filled; this does not confirm website acceptance or form submission.") + } } else { pterm.Println(vaultFillUncertain) } diff --git a/cmd/vaults_fill_test.go b/cmd/vaults_fill_test.go index 868f2048..799849c2 100644 --- a/cmd/vaults_fill_test.go +++ b/cmd/vaults_fill_test.go @@ -326,17 +326,10 @@ func TestVaultFillCLIOutcomesAndFailures(t *testing.T) { } } -func TestVaultFillCLIValidationAndAuthorizeCompatibility(t *testing.T) { +func TestVaultFillCLIValidationDoesNotReachAPI(t *testing.T) { var calls atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) - w.Header().Set("Content-Type", "application/json") - if r.Method == http.MethodPost { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - assert.JSONEq(t, `{"type":"authorize"}`, string(body)) - } - _, _ = io.WriteString(w, requestedCardFixture) })) defer server.Close() for _, raw := range []string{`{"credential-sentinel":`, `{"type":"credential-sentinel"}`, `[]`, ``} { @@ -347,11 +340,29 @@ func TestVaultFillCLIValidationAndAuthorizeCompatibility(t *testing.T) { assert.NotEmpty(t, stderr) } assert.Zero(t, calls.Load()) - out, stderr, exit := runVaultFillCLI(t, server.URL, "authorize", "--open", "-o", "json") - assert.Zero(t, exit) - assert.Empty(t, stderr) - assert.JSONEq(t, requestedCardFixture, out) - assert.Equal(t, int32(2), calls.Load()) +} + +func TestVaultPaymentTokenFillOmitsFieldBindings(t *testing.T) { + const token = `{"id":"token-1","key":"order-token","type":"payment_token","spec":{"provider":"link","wallet":"wallet-1","browser_id":"browser-1","page_url":"https://shop.example/checkout","payment_method_id":"pm-1","amount":1234,"currency":"usd","context":"Final checkout purchase context."},"state":{"provider":"link","status":"ready"},"available_operations":[{"type":"fill","description":"Authenticate this checkout without submitting payment."}],"available_expansions":[]}` + 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, token) + return + } + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"fill","browser_id":"browser-1","page_url":"https://shop.example/checkout","fields":[]}`, string(body)) + _, _ = io.WriteString(w, `{"type":"fill","status":"completed","instruction":"Payment credentials are filled. Submit the checkout form when ready.","fields":[]}`) + }) + params := `{"browser_id":"browser-1","page_url":"https://shop.example/checkout"}` + out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-token", "fill", "--params", params, "-o", "json") + require.NoError(t, err) + assert.Contains(t, out, `"instruction": "Payment credentials are filled. Submit the checkout form when ready."`) + _, human, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-token", "fill", "--params", params) + require.NoError(t, err) + assert.Contains(t, human, "Submit the checkout form when ready") + assert.NotContains(t, human, "Field index") } func TestVaultFillSingleFieldAndHint(t *testing.T) { @@ -375,23 +386,3 @@ func TestVaultFillSingleFieldAndHint(t *testing.T) { require.NoError(t, err) assert.Contains(t, human, "Invoke: kernel vaults items invoke --project=chosen-project --params '' -- checkout order-1 fill") } - -func TestVaultAuthorizeRejectsFillResponse(t *testing.T) { - for _, response := range []string{completedFillFixture, "null"} { - t.Run(response, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method == http.MethodGet { - _, _ = io.WriteString(w, requestedCardFixture) - return - } - _, _ = io.WriteString(w, response) - })) - defer server.Close() - out, stderr, exit := runVaultFillCLI(t, server.URL, "authorize", "-o", "json") - assert.Equal(t, 1, exit) - assert.Empty(t, out) - assert.Contains(t, strings.ToLower(stderr), "unexpected vault operation response") - }) - } -} diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index 8e3dc275..e2f3669e 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -48,6 +48,10 @@ type AgentCardWalletSpec = { ` const vaultCardSpecHelp = ` +Create the card only after reaching final checkout and gathering the final spend details. +Creation starts human approval. Card requests are immutable; changed purchase details +require cancellation and a new item. Never replace an uncertain payment. + type LinkCardSpec = { provider: "link"; wallet: string; // wallet item key @@ -72,6 +76,10 @@ type AgentCardCardSpec = { card_id?: string; // vc_...; otherwise chosen at approval }; +Permitted domains are provider-assigned, not configurable in the spec. +` + +const vaultLinkPurchaseTypesHelp = ` type LinkLineItem = { name: string; quantity?: number; // integer >= 1 @@ -89,6 +97,28 @@ type LinkTotal = { display_text: string; amount: number; // integer minor units }; +` -Permitted domains are provider-assigned, not configurable in the spec. +const vaultPaymentTokenSpecHelp = ` +Create a Link payment token only after reaching final checkout. Kernel inspects the +vault-linked browser, reveals Link's agent controls, and binds the request to the +observed Stripe merchant. Do not inspect hidden controls or provide merchant_account_id. +If creation returns lpt_not_supported, create a card instead. No other error is a +fallback signal. Creation starts human approval and requests are immutable. +After approval, invoke fill; filling authenticates the checkout but does not submit it. + +type LinkPaymentTokenSpec = { + provider: "link"; + wallet: string; // connected wallet item key + browser_id: string; // active vault-linked browser session ID + page_url: string; // exact final checkout page URL + payment_method_id: string; // from wallets payment-methods + amount: number; // integer minor units; 1..500000 + currency: string; // three letters + context: string; // at least 100 characters + line_items?: LinkLineItem[]; + totals?: LinkTotal[]; + metadata?: Record; + expires_at?: number; // int64 +}; ` diff --git a/cmd/vaults_invoke_test.go b/cmd/vaults_invoke_test.go index 82adf78a..1f8a7b61 100644 --- a/cmd/vaults_invoke_test.go +++ b/cmd/vaults_invoke_test.go @@ -5,44 +5,34 @@ import ( "fmt" "io" "net/http" - "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestVaultInvokeUsesAdvertisedTypeAcrossItems(t *testing.T) { +func TestVaultInvokeCollect(t *testing.T) { t.Setenv("KERNEL_PROJECT", "") - operation := `[{"type":"refresh","description":"Refresh this item explicitly."}]` - for _, fixture := range []string{ - strings.ReplaceAll(requestedCardFixture, `[{"type":"authorize","description":"Use only after explicit user approval."}]`, operation), - strings.ReplaceAll(connectedWalletFixture, `"available_operations":[]`, `"available_operations":`+operation), - strings.ReplaceAll(strings.ReplaceAll(connectedWalletFixture, `"provider":"link"`, `"provider":"agentcard"`), `"available_operations":[]`, `"available_operations":`+operation), - } { - t.Run(fixture, func(t *testing.T) { - calls := 0 - client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { - calls++ - if calls == 1 { - assert.Equal(t, http.MethodGet, r.Method) - } else { - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "/vaults/checkout/items/item-1/operations", r.URL.Path) - body, err := io.ReadAll(r.Body) - require.NoError(t, err) - assert.JSONEq(t, `{"type":"refresh"}`, string(body)) - } - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, fixture) - }) - out, human, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "item-1", "refresh", "-o", "json") + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + if calls == 1 { + assert.Equal(t, http.MethodGet, r.Method) + } else { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/vaults/checkout/items/login/operations", r.URL.Path) + body, err := io.ReadAll(r.Body) require.NoError(t, err) - assert.Equal(t, 2, calls) - assert.JSONEq(t, fixture, out) - assert.Empty(t, human) - }) - } + assert.JSONEq(t, `{"type":"collect"}`, string(body)) + } + _, _ = io.WriteString(w, credentialFixture) + }) + out, human, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "login", "collect", "-o", "json") + require.NoError(t, err) + assert.Equal(t, 2, calls) + assert.Contains(t, out, `"type": "credential"`) + assert.Empty(t, human) } func TestVaultInvokeRejectsUnadvertisedOperation(t *testing.T) { @@ -54,8 +44,8 @@ func TestVaultInvokeRejectsUnadvertisedOperation(t *testing.T) { w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, requestedCardFixture) }) - _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "refresh") - require.ErrorContains(t, err, `operation "refresh" is not advertised`) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "authorize") + require.ErrorContains(t, err, `operation "authorize" is not advertised`) assert.Equal(t, 1, calls) } @@ -71,7 +61,7 @@ func TestVaultInvokeGetFailureDoesNotPostOrRetry(t *testing.T) { w.WriteHeader(status) _, _ = io.WriteString(w, `{"code":"item_unavailable","message":"Item unavailable"}`) }) - _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "authorize") + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "login", "collect") require.ErrorContains(t, err, "item_unavailable: Item unavailable") assert.Equal(t, 1, calls) }) @@ -84,8 +74,8 @@ func TestVaultInvokeArgumentsAndHelp(t *testing.T) { for _, args := range [][]string{ {"checkout", "order-1"}, {"checkout", "order-1", ""}, - {"checkout", "order-1", "authorize", "extra"}, - {"checkout", "order-1", "authorize", "--spec", "{}"}, + {"checkout", "order-1", "fill", "extra"}, + {"checkout", "order-1", "collect", "--params", "{}"}, } { _, _, err := executeVaultCommand(t, client, append([]string{"vaults", "items", "invoke"}, args...)...) require.Error(t, err) @@ -96,8 +86,8 @@ func TestVaultInvokeArgumentsAndHelp(t *testing.T) { assert.NotNil(t, cmd.Flags().Lookup("open")) assert.NotNil(t, cmd.Flags().Lookup("params")) assert.Contains(t, cmd.Long, "failed/unknown exit nonzero") - assert.Contains(t, cmd.Long, `{"type":"authorize"}`) - assert.Contains(t, cmd.Long, "available_operations") + assert.Contains(t, cmd.Long, "there is no separate authorize operation") + assert.Contains(t, cmd.Long, "Payment tokens require") } func TestVaultInvokeOpensOnlyReturnedActionExplicitly(t *testing.T) { @@ -106,25 +96,19 @@ func TestVaultInvokeOpensOnlyReturnedActionExplicitly(t *testing.T) { calls := 0 client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { calls++ - body := requestedCardFixture - if r.Method == http.MethodPost { - body = strings.ReplaceAll(body, `"status":"requested"`, `"status":"pending_authorization"`) - body = strings.ReplaceAll(body, `"available_operations":`, `"action":{"name":"spend_approval","url":"https://provider.example/approve"},"available_operations":`) - } w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, body) + _, _ = io.WriteString(w, credentialFixture) }) opened := "" c := VaultsCmd{vaults: &client.Vaults, openURL: func(url string) error { opened = url; return nil }} var err error - out := captureStdout(t, func() { - err = c.Invoke(context.Background(), "checkout", "order-1", "authorize", nil, "json", open) + captureStdout(t, func() { + err = c.Invoke(context.Background(), "checkout", "login", "collect", nil, "json", open) }) require.NoError(t, err) assert.Equal(t, 2, calls) - assert.Contains(t, out, `"status": "pending_authorization"`) if open { - assert.Equal(t, "https://provider.example/approve", opened) + assert.Equal(t, "https://vault.kernel.sh/collect#token=item.random", opened) } else { assert.Empty(t, opened) } @@ -132,55 +116,32 @@ func TestVaultInvokeOpensOnlyReturnedActionExplicitly(t *testing.T) { } } -func TestVaultGetOperationHintUsesExplicitProject(t *testing.T) { - t.Setenv("KERNEL_PROJECT", "other-project") - client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "chosen-project", r.Header.Get("X-Kernel-Project")) - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, requestedCardFixture) - }) - _, human, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "order-1", "--project", "chosen-project") - require.NoError(t, err) - assert.Contains(t, human, "Invoke: kernel vaults items invoke --project=chosen-project -- checkout order-1 authorize") - assert.NotContains(t, human, "other-project") -} - func TestVaultGetOperationHints(t *testing.T) { for _, project := range []string{"", "project-1", "team's $(touch /tmp/nope)"} { - for _, wallet := range []bool{false, true} { - t.Run(fmt.Sprint(project, wallet), func(t *testing.T) { - t.Setenv("KERNEL_PROJECT", project) - fixture := requestedCardFixture - if wallet { - fixture = strings.ReplaceAll(connectedWalletFixture, `"available_operations":[]`, `"available_operations":[{"type":"refresh","description":"Refresh this item explicitly."}]`) - } - 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, fixture) - }) - _, human, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "item-1") - require.NoError(t, err) - op := "authorize" - if wallet { - op = "refresh" - } - assert.Contains(t, human, "Available operation: "+op) - assert.Contains(t, human, "Invoke: kernel vaults items invoke") - assert.Contains(t, human, " -- checkout item-1 "+op) - switch project { - case "": - assert.NotContains(t, human, "--project") - case "project-1": - assert.Contains(t, human, "--project=project-1") - default: - assert.Contains(t, human, `--project='team'\''s $(touch /tmp/nope)'`) - } - out, human, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "item-1", "-o", "json") - require.NoError(t, err) - assert.JSONEq(t, fixture, out) - assert.Empty(t, human) + t.Run(project, func(t *testing.T) { + t.Setenv("KERNEL_PROJECT", project) + 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, credentialFixture) }) - } + _, human, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "login") + require.NoError(t, err) + assert.Contains(t, human, "Available operation: collect") + assert.Contains(t, human, "Invoke: kernel vaults items invoke") + assert.Contains(t, human, " -- checkout login collect") + switch project { + case "": + assert.NotContains(t, human, "--project") + case "project-1": + assert.Contains(t, human, "--project=project-1") + default: + assert.Contains(t, human, `--project='team'\''s $(touch /tmp/nope)'`) + } + out, human, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "login", "-o", "json") + require.NoError(t, err) + assert.Contains(t, out, `"type": "credential"`) + assert.Empty(t, human) + }) } } diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go index c1f8ca86..7ff06bf8 100644 --- a/cmd/vaults_operation_params.go +++ b/cmd/vaults_operation_params.go @@ -73,8 +73,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" { - return nil, fmt.Errorf("--open is only supported for authorize, collect, and prepare_checkout") + if openSet && operation != "collect" && operation != "prepare_checkout" { + return nil, fmt.Errorf("--open is only supported for collect and prepare_checkout") } if len(raw) > 128*1024 { return nil, fmt.Errorf("operation parameters exceed 128 KiB") @@ -91,12 +91,12 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( } if operation != "fill" { if paramsSet { - return nil, fmt.Errorf("--params is only supported for fill and prepare_checkout; authorize takes no parameters") + return nil, fmt.Errorf("--params is only supported for fill and prepare_checkout") } return nil, nil } if !paramsSet { - return nil, fmt.Errorf("fill requires --params or --spec-file with browser_id and fields") + return nil, fmt.Errorf("fill requires --params or --spec-file with browser_id") } fill, err := parseVaultFillParams(raw) if err != nil { @@ -129,8 +129,10 @@ func parseVaultFillParams(raw string) (*vaultFillParams, error) { } } var fields []json.RawMessage - if json.Unmarshal(object["fields"], &fields) != nil || len(fields) < 1 || len(fields) > 32 { - return nil, fmt.Errorf("fields must be an array of 1-32 field bindings") + if rawFields, present := object["fields"]; present { + if json.Unmarshal(rawFields, &fields) != nil || len(fields) < 1 || len(fields) > 32 { + return nil, fmt.Errorf("fields must be an array of 1-32 field bindings") + } } params.Fields = make([]vaultFillField, 0, len(fields)) for i, rawField := range fields { diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 4e34a649..83d5f957 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -32,14 +32,14 @@ var vaultMethodFields = vaultOutputFields{ "capabilities": {"single_use_card": vaultFieldsOf("eligible reasons")}, } var vaultItemFields = vaultOutputFields{ - "id": nil, "key": nil, "type": nil, "version": nil, "created_at": nil, "updated_at": nil, "expires_at": nil, + "id": nil, "key": nil, "type": nil, "description": nil, "version": nil, "created_at": nil, "updated_at": nil, "expires_at": nil, "available_operations": vaultOperationFields, "available_expansions": vaultOperationFields, "action": vaultFieldsOf("name url expires_at"), "expanded": {"payment_methods": vaultMethodFields}, "spec": { "provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil, - "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, + "browser_id": nil, "page_url": nil, "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, "context": nil, "expires_at": nil, "description": nil, "fields": {"*": vaultFieldsOf("type required sensitive")}, "provider_config": vaultFieldsOf("id name"), @@ -259,6 +259,25 @@ func printVaultOperationHints(item *kernel.VaultItemUnion, vault, key, project s return nil } +type vaultPaymentTokenDisplay struct { + Spec struct { + BrowserID string `json:"browser_id"` + PageURL string `json:"page_url"` + Wallet string `json:"wallet"` + PaymentMethodID string `json:"payment_method_id"` + Amount int64 `json:"amount"` + Currency string `json:"currency"` + } `json:"spec"` +} + +func vaultItemDescription(item *kernel.VaultItemUnion) string { + var value struct { + Description string `json:"description"` + } + _ = json.Unmarshal([]byte(item.RawJSON()), &value) + return value.Description +} + func printVaultItem(item *kernel.VaultItemUnion, output string) error { raw, err := filterVaultJSON(json.RawMessage(item.RawJSON()), vaultItemFields) if err != nil { @@ -280,6 +299,9 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { {"Property", "Value"}, {"Key (immutable)", item.Key}, {"ID", item.ID}, {"Type", item.Type}, {"Provider", item.Spec.Provider}, {"Status", item.State.Status}, } + if description := vaultItemDescription(item); description != "" { + rows = append(rows, []string{"Description", description}) + } if item.Type == "credential" { rows = append(rows, []string{"Version", fmt.Sprint(item.Version)}) pterm.Info.Println("Use -o json for field definitions, presence, and non-sensitive values; sensitive values are omitted") @@ -300,6 +322,19 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { if item.State.StatusReason != "" { rows = append(rows, []string{"Status reason", item.State.StatusReason}) } + if item.Type == "payment_token" { + var token vaultPaymentTokenDisplay + if json.Unmarshal([]byte(item.RawJSON()), &token) != nil { + return fmt.Errorf("invalid payment token response") + } + rows = append(rows, + []string{"Wallet key", token.Spec.Wallet}, + []string{"Amount (minor units)", fmt.Sprintf("%d %s", token.Spec.Amount, token.Spec.Currency)}, + []string{"Payment method ID", token.Spec.PaymentMethodID}, + []string{"Browser session ID", token.Spec.BrowserID}, + []string{"Checkout page", token.Spec.PageURL}, + ) + } if item.Type == "card" { merchant := item.Spec.MerchantName if item.Spec.Provider == "agentcard" { @@ -393,6 +428,8 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction pterm.Info.Println("Aliases are non-secret checkout values. Use only in a browser created with this vault attached; ready does not mean paid.") } pterm.Info.Println("Inspect items events for payment outcomes. Never retry automatically; if recovery permits abandonment, delete the card only after explicit user confirmation before creating a replacement.") + } else if item.Type == "payment_token" { + pterm.Info.Println("Inspect items events for payment outcomes. Fill authenticates checkout but does not submit payment. Do not retry failed or indeterminate payments.") } else { wallet := item.AsWallet() for _, expansion := range wallet.AvailableExpansions { @@ -422,7 +459,7 @@ func printVaultPaymentMethods(methods []kernel.VaultPaymentMethod) { 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, ", ")}) } PrintTableNoPad(rows, true) - 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.") + pterm.Info.Println("Select an ID explicitly: try a Link payment token with payment_method_id first, then create a Link card only when the API returns lpt_not_supported. AgentCard uses card_id (or omit it for cardholder selection). Capabilities are advisory; missing means unknown, not ineligible.") } func printVaultEvents(events []kernel.VaultItemEvent, data []vaultJSON) { diff --git a/cmd/vaults_output_test.go b/cmd/vaults_output_test.go index 93d1d78d..822b61b1 100644 --- a/cmd/vaults_output_test.go +++ b/cmd/vaults_output_test.go @@ -46,11 +46,12 @@ func TestVaultOutputAliasesPresenceAndRedaction(t *testing.T) { buf.Reset() require.NoError(t, printVaultItem(&item, "")) assert.NotContains(t, buf.String(), "Checkout alias") - assert.Contains(t, buf.String(), "Available operation: authorize") + assert.Contains(t, buf.String(), "Required action") + assert.NotContains(t, buf.String(), "Available operation: authorize") out = captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) assert.NotContains(t, out, "aliases") - nullAliases := strings.Replace(requestedCardFixture, `"status":"requested"`, `"status":"requested","aliases":null`, 1) + nullAliases := strings.Replace(requestedCardFixture, `"status":"pending_authorization"`, `"status":"pending_authorization","aliases":null`, 1) require.NoError(t, json.Unmarshal([]byte(nullAliases), &item)) buf.Reset() require.NoError(t, printVaultItem(&item, "")) @@ -59,6 +60,16 @@ func TestVaultOutputAliasesPresenceAndRedaction(t *testing.T) { assert.Contains(t, out, `"aliases": null`) } +func TestVaultPaymentTokenHumanOutput(t *testing.T) { + var item kernel.VaultItemUnion + require.NoError(t, json.Unmarshal([]byte(paymentTokenFixture), &item)) + buf := capturePtermOutput(t) + require.NoError(t, printVaultItem(&item, "")) + for _, text := range []string{"Wallet key", "wallet-1", "Amount (minor units)", "1234 usd", "Payment method ID", "pm-1", "Browser session ID", "browser-1", "Checkout page", "https://shop.example/checkout", "does not submit payment"} { + assert.Contains(t, buf.String(), text) + } +} + func TestVaultOutputAgentCardAuthorizationIsNotPaymentSuccess(t *testing.T) { var item kernel.VaultItemUnion require.NoError(t, json.Unmarshal([]byte(`{ @@ -104,7 +115,7 @@ func TestVaultOutputPaymentMethodsAdvisoryUnknownVsFalse(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(body), &item)) buf := capturePtermOutput(t) require.NoError(t, printVaultItem(&item, "")) - for _, text := range []string{"Payment method ID", "pm-unknown", "unknown", "pm-ineligible", "false", "not_supported", "payment_method_id", "--spec JSON", "advisory"} { + for _, text := range []string{"Payment method ID", "pm-unknown", "unknown", "pm-ineligible", "false", "not_supported", "payment_method_id", "payment token", "advisory"} { assert.Contains(t, buf.String(), text) } out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) diff --git a/cmd/vaults_policy_test.go b/cmd/vaults_policy_test.go index 2b364fec..faeddbfb 100644 --- a/cmd/vaults_policy_test.go +++ b/cmd/vaults_policy_test.go @@ -12,11 +12,11 @@ import ( ) func TestVaultRecoveryActionDisplayPolicy(t *testing.T) { - for _, status := range []string{"requested", "recovery_required"} { + for _, status := range []string{"pending_authorization", "recovery_required"} { for _, command := range []string{"get", "list"} { for _, output := range []string{"", "json"} { t.Run(status+"/"+command+"/"+output, func(t *testing.T) { - body := strings.ReplaceAll(requestedCardFixture, `"status":"requested"`, `"status":"`+status+`"`) + body := strings.ReplaceAll(requestedCardFixture, `"status":"pending_authorization"`, `"status":"`+status+`"`) var fields map[string]json.RawMessage require.NoError(t, json.Unmarshal([]byte(body), &fields)) fields["action"] = json.RawMessage(`{"name":"spend_approval","url":"https://example.test/approve"}`) @@ -42,7 +42,7 @@ func TestVaultRecoveryActionDisplayPolicy(t *testing.T) { if output == "json" { assert.Contains(t, out, `"spend_approval"`) assert.Contains(t, out, `"available_operations"`) - assert.Contains(t, out, `"authorize"`) + assert.NotContains(t, out, `"authorize"`) assert.Empty(t, human) } else if status == "recovery_required" { assert.NotContains(t, human, "spend_approval") diff --git a/cmd/vaults_secrets.go b/cmd/vaults_secrets.go index 3616ad0f..b0f1ef83 100644 --- a/cmd/vaults_secrets.go +++ b/cmd/vaults_secrets.go @@ -14,6 +14,40 @@ import ( // Do not wrap SDK errors here: response bodies and transport errors can echo // write-only credentials, and the root command unwraps SDK errors for display. +func vaultPaymentTokenError(err error) error { + var apiErr *kernel.Error + if errors.As(err, &apiErr) { + var body struct { + Code string `json:"code"` + } + if json.Unmarshal([]byte(apiErr.RawJSON()), &body) == nil { + if apiErr.StatusCode == 400 && body.Code == "lpt_not_supported" { + return fmt.Errorf("lpt_not_supported: this checkout does not support a Link payment token; create a card instead") + } + discoveryFailure := false + switch body.Code { + case "page_not_found", "ambiguous_page", "timeout": + discoveryFailure = apiErr.StatusCode == 400 + case "destination_denied": + discoveryFailure = apiErr.StatusCode == 403 + case "browser_not_found": + discoveryFailure = apiErr.StatusCode == 404 + case "browser_unavailable": + discoveryFailure = apiErr.StatusCode == 409 + case "browser_error": + discoveryFailure = apiErr.StatusCode == 500 + } + if discoveryFailure { + return fmt.Errorf("%s: payment-token checkout discovery failed before a spend was created; correct the browser or page and retry", body.Code) + } + if apiErr.StatusCode == 409 && body.Code == "conflict" { + return fmt.Errorf("vault conflict (HTTP 409); inspect current state and immutable bindings") + } + } + } + return vaultCredentialError(err) +} + func vaultCredentialError(err error) error { var apiErr *kernel.Error if errors.As(err, &apiErr) { @@ -71,7 +105,7 @@ func vaultSpecHasSecrets(value json.RawMessage) bool { } for key, child := range object { switch strings.ToLower(key) { - case "tokens", "access_token", "refresh_token", "client_secret", "credentials": + case "tokens", "access_token", "refresh_token", "link_pay_token", "client_secret", "credentials": return true case "authorization", "client", "provider_config": if vaultSpecHasSecrets(child) { diff --git a/cmd/vaults_spec_test.go b/cmd/vaults_spec_test.go index b5b31c99..4d0fc331 100644 --- a/cmd/vaults_spec_test.go +++ b/cmd/vaults_spec_test.go @@ -13,7 +13,7 @@ import ( func TestVaultRawSpecForwarding(t *testing.T) { t.Setenv("KERNEL_PROJECT", "") - for _, path := range []string{"wallets create", "cards create", "cards update"} { + for _, path := range []string{"wallets create", "cards create"} { for _, provider := range []string{"link", "agentcard"} { for _, raw := range []string{ `{}`, @@ -34,13 +34,8 @@ func TestVaultRawSpecForwarding(t *testing.T) { require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) assert.Equal(t, expected, body.Spec, "preserve exact numbers, false, zero, null, nested fields, and omissions") assert.Equal(t, "/vaults/checkout/items/item-1", r.URL.Path) - if path == "cards update" { - assert.Equal(t, http.MethodPatch, r.Method) - assert.Equal(t, "card", body.Type) - } else { - assert.Equal(t, http.MethodPut, r.Method) - assert.Equal(t, strings.TrimSuffix(strings.Fields(path)[0], "s"), body.Type) - } + assert.Equal(t, http.MethodPut, r.Method) + assert.Equal(t, strings.TrimSuffix(strings.Fields(path)[0], "s"), body.Type) w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, requestedCardFixture) }) @@ -55,6 +50,56 @@ func TestVaultRawSpecForwarding(t *testing.T) { } } +func TestVaultPaymentTokenRequestKeepsMerchantBindingServerOwned(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + var body struct { + Type string `json:"type"` + Spec map[string]json.RawMessage `json:"spec"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "payment_token", body.Type) + assert.JSONEq(t, `"link"`, string(body.Spec["provider"])) + assert.NotContains(t, body.Spec, "merchant_account_id") + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, paymentTokenFixture) + }) + spec := `{"wallet":"wallet-1","browser_id":"browser-1","page_url":"https://shop.example/checkout","payment_method_id":"pm-1","amount":1234,"currency":"usd","context":"Final checkout purchase context."}` + out, _, err := executeVaultCommand(t, client, "vaults", "payment-tokens", "create", "checkout", "order-token", "--spec", spec, "-o", "json") + require.NoError(t, err) + assert.Contains(t, out, `"type": "payment_token"`) + assert.Contains(t, out, `"browser_id": "browser-1"`) +} + +func TestVaultPaymentTokenFallbackOnlyForUnsupportedCheckout(t *testing.T) { + for _, tc := range []struct { + status int + code string + want string + }{ + {400, "lpt_not_supported", "create a card instead"}, + {409, "browser_unavailable", "correct the browser or page and retry"}, + {409, "conflict", "vault conflict"}, + {500, "provider_error", "outcome may be unresolved"}, + } { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.status) + _, _ = io.WriteString(w, `{"code":"`+tc.code+`","message":"detail"}`) + }) + spec := `{"wallet":"wallet-1","browser_id":"browser-1","page_url":"https://shop.example/checkout","payment_method_id":"pm-1","amount":1234,"currency":"usd","context":"Final checkout purchase context."}` + _, _, err := executeVaultCommand(t, client, "vaults", "payment-tokens", "create", "checkout", "order-token", "--spec", spec) + require.ErrorContains(t, err, tc.want) + if tc.code != "lpt_not_supported" { + assert.NotContains(t, err.Error(), "create a card") + } + if tc.code == "conflict" { + assert.NotContains(t, err.Error(), "retry") + } + } +} + func TestVaultRawSpecValidationIsLeftToAPI(t *testing.T) { t.Setenv("KERNEL_PROJECT", "") client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { @@ -71,8 +116,18 @@ func TestVaultRawSpecValidationIsLeftToAPI(t *testing.T) { require.ErrorContains(t, err, "invalid_request: wallet is required") } +func TestVaultPaymentTokenHelp(t *testing.T) { + cmd, _, err := newVaultsCommand().Find([]string{"payment-tokens", "create"}) + require.NoError(t, err) + for _, text := range []string{"browser_id", "page_url", "lpt_not_supported", "merchant_account_id", "immutable", "does not submit"} { + assert.Contains(t, cmd.Long, text) + } + assert.Nil(t, cmd.Flags().Lookup("provider")) + assert.NotNil(t, cmd.Flags().Lookup("spec")) +} + func TestVaultSpecHelpAndFlags(t *testing.T) { - for _, path := range []string{"wallets create", "cards create", "cards update"} { + for _, path := range []string{"wallets create", "cards create"} { t.Run(path, func(t *testing.T) { cmd, _, err := newVaultsCommand().Find(strings.Fields(path)) require.NoError(t, err) diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go index 14942156..60e50e30 100644 --- a/cmd/vaults_test.go +++ b/cmd/vaults_test.go @@ -22,8 +22,10 @@ import ( const linkWalletSpecFixture = `{"authorization":{"method":"oauth","client":{"type":"kernel_managed"}}}` const vaultFixture = `{"id":"vault-1","name":"checkout","created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-01T00:00:00Z"}` -const requestedCardFixture = `{"id":"item-1","key":"order-1","type":"card","spec":{"provider":"link","wallet":"wallet-1","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example","context":"Purchase description"},"state":{"provider":"link","status":"requested"},"available_operations":[{"type":"authorize","description":"Use only after explicit user approval."}],"available_expansions":[]}` -const connectedWalletFixture = `{"id":"wallet-id","key":"wallet-1","type":"wallet","spec":{"provider":"link","authorization":{"method":"oauth","client":{"type":"kernel_managed"}}},"state":{"provider":"link","status":"connected"},"available_operations":[],"available_expansions":[{"type":"payment_methods","description":"Select a payment method explicitly."}]}` +const requestedCardFixture = `{"id":"item-1","key":"order-1","type":"card","spec":{"provider":"link","wallet":"wallet-1","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example","context":"Purchase description"},"state":{"provider":"link","status":"pending_authorization"},"action":{"name":"spend_approval","url":"https://example.com/approve"},"available_operations":[],"available_expansions":[]}` +const connectedWalletFixture = `{"id":"wallet-id","key":"wallet-1","type":"wallet","description":"Reach checkout before creating a credential.","spec":{"provider":"link","authorization":{"method":"oauth","client":{"type":"kernel_managed"}}},"state":{"provider":"link","status":"connected"},"available_operations":[],"available_expansions":[{"type":"payment_methods","description":"Select a payment method explicitly."}]}` +const paymentTokenFixture = `{"id":"token-1","key":"order-token","type":"payment_token","description":"Approve this checkout-bound Link credential.","spec":{"provider":"link","wallet":"wallet-1","browser_id":"browser-1","page_url":"https://shop.example/checkout","payment_method_id":"pm-1","amount":1234,"currency":"usd","context":"Final checkout purchase context."},"state":{"provider":"link","status":"pending_authorization"},"action":{"name":"spend_approval","url":"https://example.com/approve"},"available_operations":[],"available_expansions":[]}` +const fillCardFixture = `{"id":"item-1","key":"order-1","type":"card","spec":{"provider":"link","wallet":"wallet-1","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example","context":"Purchase description"},"state":{"provider":"link","status":"ready"},"available_operations":[{"type":"fill","description":"Fill this approved credential without submitting payment."}],"available_expansions":[]}` func vaultTestClient(t *testing.T, handler http.HandlerFunc) kernel.Client { t.Helper() @@ -52,7 +54,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"} { + for _, path := range []string{"create", "list", "get", "delete", "items list", "items get", "items delete", "items events", "wallets create", "wallets payment-methods", "cards create", "payment-tokens create", "items invoke"} { t.Run(path, func(t *testing.T) { cmd, remaining, err := newVaultsCommand().Find(strings.Fields(path)) require.NoError(t, err) @@ -71,7 +73,7 @@ func TestVaultCommandConstruction(t *testing.T) { cmd, _, err := rootCmd.Find([]string{"vaults", "items", "invoke"}) require.NoError(t, err) assert.False(t, isAuthExempt(cmd)) - for _, unsupported := range []string{"rename", "update", "items put", "items action", "wallets callback", "cards pay", "cards authorize"} { + for _, unsupported := range []string{"rename", "update", "items put", "items action", "wallets callback", "cards pay", "cards authorize", "cards update"} { cmd, remaining, _ := newVaultsCommand().Find(strings.Fields(unsupported)) assert.True(t, len(remaining) > 0 || cmd.RunE == nil, unsupported) } @@ -103,7 +105,7 @@ func TestVaultRequiredAndInvalidFlags(t *testing.T) { {"vaults wallets create checkout wallet-1 --provider unknown --spec {}", "--provider"}, {"vaults wallets create checkout wallet-1 --provider link", "required flag"}, {"vaults cards create checkout order-1 --provider link", "required flag"}, - {"vaults cards update checkout order-1 --spec {}", "required flag"}, + {"vaults payment-tokens create checkout order-1", "required flag"}, {"vaults wallets create checkout wallet-1 --provider link --user-id usr_123", "--user-id"}, {"vaults wallets create checkout wallet-1 --provider agentcard --user-id wrong", "--user-id"}, {"vaults cards create checkout order-1", "required flag"}, @@ -139,8 +141,7 @@ func TestVaultCommandsWithoutProject(t *testing.T) { {[]string{"wallets", "create", "checkout", "wallet-1", "--provider", "link", "--spec", linkWalletSpecFixture}, connectedWalletFixture, 1}, {[]string{"wallets", "payment-methods", "checkout", "wallet-1"}, connectedWalletFixture, 1}, {append([]string{"cards", "create", "checkout", "order-1"}, linkCardArgs()...), requestedCardFixture, 1}, - {append([]string{"cards", "update", "checkout", "order-1"}, linkCardArgs()...), requestedCardFixture, 1}, - {[]string{"items", "invoke", "checkout", "order-1", "authorize"}, requestedCardFixture, 2}, + {[]string{"payment-tokens", "create", "checkout", "order-token", "--spec", `{"wallet":"wallet-1","browser_id":"browser-1","page_url":"https://shop.example/checkout","payment_method_id":"pm-1","amount":1234,"currency":"usd","context":"Final checkout purchase context."}`}, paymentTokenFixture, 1}, } for _, tt := range tests { t.Run(strings.Join(tt.args[:min(2, len(tt.args))], " "), func(t *testing.T) { @@ -171,7 +172,7 @@ func linkCardArgs() []string { func TestVaultSpecValidation(t *testing.T) { t.Setenv("KERNEL_PROJECT", "") client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid JSON reached API") }) - for _, path := range []string{"wallets create", "cards create", "cards update"} { + for _, path := range []string{"wallets create", "cards create"} { for _, spec := range []string{"", "null", "[]", "42", `"text"`, "{", "{} {}", `{"provider":"agentcard"}`, `{"provider":null}`, `{"provider":1}`} { t.Run(path+"/"+spec, func(t *testing.T) { args := append([]string{"vaults"}, strings.Fields(path)...) @@ -264,84 +265,76 @@ func TestVaultWalletRequestMapping(t *testing.T) { func TestVaultCardRequestMapping(t *testing.T) { t.Setenv("KERNEL_PROJECT", "project-test") - for _, operation := range []string{"create", "update"} { - for _, provider := range []string{"link", "agentcard"} { - t.Run(operation+provider, func(t *testing.T) { - calls := 0 - client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { - calls++ - expectedMethod := http.MethodPut - if operation == "update" { - expectedMethod = http.MethodPatch - } - assert.Equal(t, expectedMethod, r.Method) - assert.Equal(t, "/vaults/checkout/items/order-1", r.URL.Path) - var body map[string]json.RawMessage - require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) - assert.JSONEq(t, `"card"`, string(body["type"])) - assert.Len(t, body, 2) - if provider == "link" { - assert.JSONEq(t, fmt.Sprintf(`{"provider":"link","wallet":"wallet-1","amount":1234,"currency":"USD","merchant_name":"Example Shop","merchant_url":"https://shop.example","payment_method_id":"pm-1","context":%q}`, strings.Repeat("Purchase purpose. ", 7)), string(body["spec"])) - } else { - assert.JSONEq(t, `{"provider":"agentcard","wallet":"wallet-1","amount":1234,"currency":"usd","merchant":"Example Shop","card_id":"vc_chosen"}`, string(body["spec"])) - } - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, requestedCardFixture) - }) - flags := linkCardArgs() - if provider == "agentcard" { - flags = []string{"--provider", provider, "--spec", `{"wallet":"wallet-1","amount":1234,"currency":"usd","merchant":"Example Shop","card_id":"vc_chosen"}`} + for _, provider := range []string{"link", "agentcard"} { + t.Run(provider, func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodPut, r.Method) + assert.Equal(t, "/vaults/checkout/items/order-1", r.URL.Path) + var body map[string]json.RawMessage + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.JSONEq(t, `"card"`, string(body["type"])) + assert.Len(t, body, 2) + if provider == "link" { + assert.JSONEq(t, fmt.Sprintf(`{"provider":"link","wallet":"wallet-1","amount":1234,"currency":"USD","merchant_name":"Example Shop","merchant_url":"https://shop.example","payment_method_id":"pm-1","context":%q}`, strings.Repeat("Purchase purpose. ", 7)), string(body["spec"])) + } else { + assert.JSONEq(t, `{"provider":"agentcard","wallet":"wallet-1","amount":1234,"currency":"usd","merchant":"Example Shop","card_id":"vc_chosen"}`, string(body["spec"])) } - args := append([]string{"vaults", "cards", operation, "checkout", "order-1", "-o", "json"}, flags...) - out, human, err := executeVaultCommand(t, client, args...) - require.NoError(t, err) - assert.JSONEq(t, requestedCardFixture, out) - assert.Empty(t, human) - assert.Equal(t, 1, calls, "card writes must not authorize implicitly") + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, requestedCardFixture) }) - } + flags := linkCardArgs() + if provider == "agentcard" { + flags = []string{"--provider", provider, "--spec", `{"wallet":"wallet-1","amount":1234,"currency":"usd","merchant":"Example Shop","card_id":"vc_chosen"}`} + } + args := append([]string{"vaults", "cards", "create", "checkout", "order-1", "-o", "json"}, flags...) + out, human, err := executeVaultCommand(t, client, args...) + require.NoError(t, err) + assert.JSONEq(t, requestedCardFixture, out) + assert.Empty(t, human) + assert.Equal(t, 1, calls) + }) } } -func TestVaultInvokeRequiresAdvertisedOperation(t *testing.T) { +func TestVaultInvokeRequiresAdvertisedFill(t *testing.T) { t.Setenv("KERNEL_PROJECT", "project-test") - for _, state := range []string{"requested", "pending_authorization", "ready", "consumed", "expired", "declined"} { - for _, advertised := range []bool{false, true} { - t.Run(fmt.Sprint(state, advertised), func(t *testing.T) { - getCalls, postCalls := 0, 0 - body := strings.ReplaceAll(requestedCardFixture, `"status":"requested"`, `"status":"`+state+`"`) - if !advertised { - body = strings.ReplaceAll(body, `[{"type":"authorize","description":"Use only after explicit user approval."}]`, `[]`) - } - client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method == http.MethodGet { - getCalls++ - _, _ = io.WriteString(w, body) - return - } - postCalls++ - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "/vaults/checkout/items/order-1/operations", r.URL.Path) - payload, _ := io.ReadAll(r.Body) - assert.JSONEq(t, `{"type":"authorize"}`, string(payload)) + for _, advertised := range []bool{false, true} { + t.Run(fmt.Sprint(advertised), func(t *testing.T) { + getCalls, postCalls := 0, 0 + body := fillCardFixture + if !advertised { + body = strings.ReplaceAll(body, `[{"type":"fill","description":"Fill this approved credential without submitting payment."}]`, `[]`) + } + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + getCalls++ _, _ = io.WriteString(w, body) - }) - _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "authorize", "-o", "json") - assert.Equal(t, 1, getCalls) - if advertised { - require.NoError(t, err) - assert.Equal(t, 1, postCalls) - } else { - require.ErrorContains(t, err, "not advertised in available_operations") - assert.Zero(t, postCalls) + return } + postCalls++ + payload, _ := io.ReadAll(r.Body) + assert.JSONEq(t, `{"type":"fill","browser_id":"browser","page_url":"https://shop.example","fields":[{"field":"number","selector":"#number"}]}`, string(payload)) + _, _ = io.WriteString(w, `{"type":"fill","status":"completed","instruction":"Payment credentials are filled. Submit the checkout form when ready.","fields":[{"index":0,"status":"filled"}]}`) }) - } + spec := `{"browser_id":"browser","page_url":"https://shop.example","fields":[{"field":"number","selector":"#number"}]}` + out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "fill", "--params", spec, "-o", "json") + assert.Equal(t, 1, getCalls) + if advertised { + require.NoError(t, err) + assert.Equal(t, 1, postCalls) + assert.Contains(t, out, `"instruction"`) + } else { + require.ErrorContains(t, err, "not advertised in available_operations") + assert.Zero(t, postCalls) + } + }) } } -func TestVaultNoSDKRetriesAndAPIErrorMessages(t *testing.T) { +func TestVaultFillNoSDKRetriesAndAPIErrorMessages(t *testing.T) { t.Setenv("KERNEL_PROJECT", "project-test") for _, status := range []int{409, 429, 500} { t.Run(fmt.Sprint(status), func(t *testing.T) { @@ -350,20 +343,18 @@ func TestVaultNoSDKRetriesAndAPIErrorMessages(t *testing.T) { calls++ w.Header().Set("Content-Type", "application/json") if r.Method == http.MethodGet { - _, _ = io.WriteString(w, requestedCardFixture) + _, _ = io.WriteString(w, fillCardFixture) return } w.WriteHeader(status) - _, _ = io.WriteString(w, `{"message":"Authorization service unavailable","code":"authorization_failed"}`) + _, _ = io.WriteString(w, `{"message":"Fill service unavailable","code":"fill_failed"}`) }) - out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "authorize", "-o", "json") + spec := `{"browser_id":"browser","page_url":"https://shop.example","fields":[{"field":"number","selector":"#number"}]}` + out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "fill", "--params", spec, "-o", "json") require.Error(t, err) assert.Equal(t, 2, calls) assert.Empty(t, out) - var apiErr *kernel.Error - require.ErrorAs(t, err, &apiErr) - assert.Equal(t, status, apiErr.StatusCode) - assert.Equal(t, "authorization_failed: Authorization service unavailable", util.CleanedUpSdkError{Err: err}.Error()) + assert.Contains(t, err.Error(), fmt.Sprintf("HTTP %d", status)) }) } } @@ -377,8 +368,8 @@ func TestVaultInvalidProjectErrors(t *testing.T) { {"wallets", "create", "checkout", "wallet-1", "--provider", "link", "--spec", linkWalletSpecFixture}, {"wallets", "payment-methods", "checkout", "wallet-1"}, append([]string{"cards", "create", "checkout", "order-1"}, linkCardArgs()...), - append([]string{"cards", "update", "checkout", "order-1"}, linkCardArgs()...), - {"items", "invoke", "checkout", "order-1", "authorize"}, + {"payment-tokens", "create", "checkout", "order-token", "--spec", `{"wallet":"wallet-1","browser_id":"browser","page_url":"https://shop.example","payment_method_id":"pm-1","amount":100,"currency":"usd","context":"Final checkout purchase context long enough for the payment request."}`}, + {"items", "invoke", "checkout", "order-1", "fill", "--params", `{"browser_id":"browser","page_url":"https://shop.example"}`}, } for _, project := range []string{"doesntexist", "abcdefghijklmnopqrstuvwx"} { for _, args := range commands { @@ -393,8 +384,10 @@ func TestVaultInvalidProjectErrors(t *testing.T) { }) out, human, err := executeVaultCommand(t, client, append([]string{"--project", project, "vaults"}, args...)...) require.Error(t, err) - if args[0] == "wallets" && args[1] == "create" { + if (args[0] == "wallets" || args[0] == "payment-tokens") && args[1] == "create" { assert.Equal(t, "vault resource not found (HTTP 404)", err.Error()) + } else if args[0] == "items" && args[1] == "invoke" { + assert.Equal(t, "could not retrieve vault item; fill was not invoked", err.Error()) } else { assert.Equal(t, "project_not_found: Project not found or inactive", util.CleanedUpSdkError{Err: err}.Error()) } diff --git a/cmd/vaults_wallet_config_test.go b/cmd/vaults_wallet_config_test.go index 540ef44c..b8ab8d55 100644 --- a/cmd/vaults_wallet_config_test.go +++ b/cmd/vaults_wallet_config_test.go @@ -134,7 +134,7 @@ func TestVaultWalletConfigInvalidInput(t *testing.T) { require.Error(t, err) assert.False(t, strings.Contains(out+human+err.Error(), secret)) } - for _, path := range []string{"wallets create", "cards create", "cards update"} { + for _, path := range []string{"wallets create", "cards create"} { for _, raw := range []string{ fmt.Sprintf(`{"authorization":{"tokens":{"access_token":%q}}}`, secret), fmt.Sprintf(`{"credentials":{"client_secret":%q}}`, secret), @@ -171,7 +171,7 @@ func TestVaultImportedWalletErrorSafety(t *testing.T) { func TestVaultRecoveryRequired(t *testing.T) { for _, provider := range []string{"link", "agentcard"} { - body := strings.ReplaceAll(requestedCardFixture, `"status":"requested"`, `"status":"recovery_required"`) + body := strings.ReplaceAll(requestedCardFixture, `"status":"pending_authorization"`, `"status":"recovery_required"`) body = strings.ReplaceAll(body, `"provider":"link"`, `"provider":"`+provider+`"`) for _, operation := range []string{"get", "invoke"} { t.Run(provider+"/"+operation, func(t *testing.T) { @@ -184,7 +184,7 @@ func TestVaultRecoveryRequired(t *testing.T) { }) args := []string{"vaults", "items", operation, "checkout", "order-1"} if operation == "invoke" { - args = append(args, "authorize") + args = append(args, "fill", "--params", `{"browser_id":"browser","page_url":"https://shop.example"}`) } else { args = append(args, "--wait", "60") } @@ -204,7 +204,7 @@ func TestVaultRecoveryRequired(t *testing.T) { } func TestVaultRecoveryDoesNotOpenStaleAction(t *testing.T) { - body := strings.ReplaceAll(requestedCardFixture, `"status":"requested"`, `"status":"recovery_required"`) + body := strings.ReplaceAll(requestedCardFixture, `"status":"pending_authorization"`, `"status":"recovery_required"`) body = strings.TrimSuffix(body, "}") + `,"action":{"name":"spend_approval","url":"https://example.test/approve"}}` var item kernel.VaultItemUnion require.NoError(t, json.Unmarshal([]byte(body), &item)) @@ -258,18 +258,3 @@ func TestVaultRecoveryEventProjection(t *testing.T) { assert.Contains(t, out+human, "outcome_unknown") } } - -func TestVaultPendingUpdatePreservesOmissionsAndEmptyLists(t *testing.T) { - for _, fields := range []string{"", `,"line_items":[],"totals":[]`} { - client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPatch, r.Method) - body, _ := io.ReadAll(r.Body) - assert.JSONEq(t, `{"type":"card","spec":{"provider":"link","wallet":"wallet-1","amount":2000`+fields+`}}`, string(body)) - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, strings.ReplaceAll(requestedCardFixture, "requested", "recovery_required")) - }) - out, _, err := executeVaultInputCommand(t, client, "", "vaults", "cards", "update", "checkout", "order-1", "--provider", "link", "--spec", `{"wallet":"wallet-1","amount":2000`+fields+`}`, "-o", "json") - require.NoError(t, err) - assert.Contains(t, out, "recovery_required") - } -} diff --git a/cmd/vaults_wallet_spec_test.go b/cmd/vaults_wallet_spec_test.go index 9711ddba..ef432f60 100644 --- a/cmd/vaults_wallet_spec_test.go +++ b/cmd/vaults_wallet_spec_test.go @@ -15,7 +15,7 @@ import ( func TestVaultSpecsPreserveOpaqueMetadata(t *testing.T) { metadata := `{"tokens":"loyalty-points","credentials":{"label":"member"},"client_secret":"field-description","entries":[{"access_token":"column-name"}]}` for _, provider := range []string{"link", "agentcard"} { - for _, command := range []string{"wallets create", "cards create", "cards update"} { + for _, command := range []string{"wallets create", "cards create"} { t.Run(provider+"/"+command, func(t *testing.T) { client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { var body struct { From f7acbd71eef3d4d234337c238b924ff6a86eead1 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:41:35 +0000 Subject: [PATCH 2/7] Handle value-free payment token outcomes --- cmd/vaults_fill.go | 8 ++++++++ cmd/vaults_fill_test.go | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/cmd/vaults_fill.go b/cmd/vaults_fill.go index 7ce81e16..04335e1c 100644 --- a/cmd/vaults_fill.go +++ b/cmd/vaults_fill.go @@ -149,6 +149,14 @@ func parseVaultFillResult(raw json.RawMessage, count int) (*vaultFillResult, err if json.Unmarshal(safe, &result) != nil || result.Type != "fill" || len(result.Fields) != count { return nil, invalid } + if count == 0 { + switch result.Status { + case "completed", "failed", "unknown": + return &result, nil + default: + return nil, invalid + } + } status := "completed" stopped := false for i, field := range result.Fields { diff --git a/cmd/vaults_fill_test.go b/cmd/vaults_fill_test.go index 799849c2..063afc1e 100644 --- a/cmd/vaults_fill_test.go +++ b/cmd/vaults_fill_test.go @@ -342,6 +342,17 @@ func TestVaultFillCLIValidationDoesNotReachAPI(t *testing.T) { assert.Zero(t, calls.Load()) } +func TestVaultPaymentTokenFillOutcomesHaveNoFieldBindings(t *testing.T) { + for _, status := range []string{"completed", "failed", "unknown"} { + t.Run(status, func(t *testing.T) { + result, err := parseVaultFillResult(json.RawMessage(`{"type":"fill","status":"`+status+`","fields":[]}`), 0) + require.NoError(t, err) + require.Equal(t, status, result.Status) + require.Empty(t, result.Fields) + }) + } +} + func TestVaultPaymentTokenFillOmitsFieldBindings(t *testing.T) { const token = `{"id":"token-1","key":"order-token","type":"payment_token","spec":{"provider":"link","wallet":"wallet-1","browser_id":"browser-1","page_url":"https://shop.example/checkout","payment_method_id":"pm-1","amount":1234,"currency":"usd","context":"Final checkout purchase context."},"state":{"provider":"link","status":"ready"},"available_operations":[{"type":"fill","description":"Authenticate this checkout without submitting payment."}],"available_expansions":[]}` client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { From f352bd52a83a3b666c95f317f3ad87f9f1032639 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:54:46 +0000 Subject: [PATCH 3/7] Unify Link checkout card commands --- cmd/vaults.go | 26 +++-------- cmd/vaults_commands.go | 90 ++++++++++++++------------------------- cmd/vaults_fill.go | 2 +- cmd/vaults_fill_test.go | 12 +++--- cmd/vaults_help.go | 36 ++++------------ cmd/vaults_invoke_test.go | 2 +- cmd/vaults_output.go | 37 +++++++--------- cmd/vaults_output_test.go | 8 ++-- cmd/vaults_secrets.go | 9 ++-- cmd/vaults_spec_test.go | 36 ++++++++-------- cmd/vaults_test.go | 18 +++----- 11 files changed, 98 insertions(+), 178 deletions(-) diff --git a/cmd/vaults.go b/cmd/vaults.go index 97e30765..dbb95b33 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -191,24 +191,11 @@ func (c VaultsCmd) CreateWallet(ctx context.Context, vault, key string, spec ker func (c VaultsCmd) SaveCard(ctx context.Context, vault, key string, spec kernel.CardVaultItemSpecUnionParam, output string) error { item, err := c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCard: &kernel.VaultItemUpsertParamsBodyCard{Spec: spec}}, option.WithMaxRetries(0)) if err != nil { - return util.CleanedUpSdkError{Err: err} + return vaultCardError(err) } return c.showItem(item, output, false) } -func (c VaultsCmd) CreatePaymentToken(ctx context.Context, vault, key string, spec map[string]json.RawMessage, output string) error { - body, err := json.Marshal(map[string]any{"type": "payment_token", "spec": spec}) - if err != nil { - return err - } - var item kernel.VaultItemUnion - _, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault}, option.WithRequestBody("application/json", body), option.WithResponseBodyInto(&item), option.WithMaxRetries(0)) - if err != nil { - return vaultPaymentTokenError(err) - } - return c.showItem(&item, output, false) -} - func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, params *vaultOperationParams, output string, open bool) error { if strings.TrimSpace(operation) == "" { return fmt.Errorf("operation must not be empty") @@ -250,13 +237,10 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par return fmt.Errorf("operation %q is not advertised in available_operations; inspect the item", operation) } if operation == "fill" { - if item.Type == "payment_token" { - if params.Fill.PageURL == "" || len(params.Fill.Fields) != 0 { - return fmt.Errorf("payment-token fill requires page_url and must omit fields") - } - } else if len(params.Fill.Fields) == 0 { - return fmt.Errorf("credential and card fill requires 1-32 field bindings") - } else if item.Type == "card" && params.Fill.PageURL == "" { + if item.Type == "credential" && len(params.Fill.Fields) == 0 { + return fmt.Errorf("credential fill requires 1-32 field bindings") + } + if item.Type == "card" && params.Fill.PageURL == "" { return fmt.Errorf("card fill requires page_url") } return c.fill(ctx, vault, key, params.Fill, output) diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 1920a305..36e353a1 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -75,11 +75,16 @@ Vault names, item keys, and project ownership are immutable. 1. Create/select a vault, then create a provider wallet and follow its returned action. 2. For Link, list wallet payment methods and select an ID explicitly. Create a browser with --vault , navigate to final checkout, and gather final spend details. -3. Try payment-tokens create with that browser ID and exact page URL. Only - lpt_not_supported means create a card instead. Payment credential creation starts human approval. +3. Create one Link card with that browser ID and exact page URL. Kernel inspects the + checkout and internally selects a Link payment token or virtual card. Creation starts approval. 4. Share the returned approval URL and retrieve the item until fill is advertised. -5. Invoke fill with its browser/page parameters. Fill never submits payment; inspect the - checkout and submit separately when ready. Inspect items get/events for the outcome. +5. Invoke fill with the parameters described by the advertised operation; browser-vault + attachment is required for fill. Ready Link cards use only advertised fill. Link cards + do not expose aliases or support egress substitution. AgentCard-only checkout aliases + support egress substitution with checkout hold, approval, and replay. +6. Fill never submits payment; inspect the checkout and submit separately when ready. + Virtual-card selection is creation-time fallback, not a fallback after fill. Inspect + items get/events for the outcome. Permitted checkout domains are provider-assigned and displayed when returned; there is no domain-setting API. @@ -126,7 +131,7 @@ JSON output preserves returned public fields but omits unknown/opaque provider d }} addVaultJSONOutputFlag(itemList) itemGet := &cobra.Command{Use: "get ", Short: "Get item state and any required action", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, - Long: "Get item state, available operations, provider actions, and returned checkout aliases.\n--wait is a single bounded server-side observation, not a retry or a guarantee of readiness.\nAn item still pending after the wait is returned as-is; ready means populated for credentials, not logged in or paid.\nFor credential edits on an already-ready item, compare versions without --wait. Explicitly non-sensitive text/email values are returned; sensitive values and TOTP seeds are omitted.\nrecovery_required stops waiting and means unresolved, not declined or expired.\nReconcile with the provider or support; do not retry, delete, or replace the payment.", + Long: "Get item state, available operations, provider actions, and returned AgentCard checkout aliases.\n--wait is a single bounded server-side observation, not a retry or a guarantee of readiness.\nAn item still pending after the wait is returned as-is; ready means populated for credentials, not logged in or paid.\nFor credential edits on an already-ready item, compare versions without --wait. Explicitly non-sensitive text/email values are returned; sensitive values and TOTP seeds are omitted.\nrecovery_required stops waiting and means unresolved, not declined or expired.\nReconcile with the provider or support; do not retry, delete, or replace the payment.", RunE: func(cmd *cobra.Command, args []string) error { wait, _ := cmd.Flags().GetInt64("wait") expand, _ := cmd.Flags().GetStringSlice("expand") @@ -153,8 +158,9 @@ collect returns a time-scoped URL for the full credential form without clearing Payment credential creation starts approval; there is no separate authorize operation. Read the operation description and follow any approval requirements before invoking. fill requires --params JSON or --spec-file with browser_id (session ID, not name). -Credentials and cards require 1-32 ordered fields (field, selector). Payment tokens require -an exact page_url and must omit fields. Do not include type, values, or frame IDs. +Credentials require 1-32 ordered fields (field, selector). Link cards require an exact +page_url; include fields for a virtual-card fill and omit fields when the advertised operation +says Kernel will use WebMCP. Do not include type, values, or frame IDs. The vault must already be attached to the browser. page_url selects an existing page; fill never navigates. Credentials use declared field names, must omit format, and may omit page_url only when the API can resolve a unique page. TOTP codes stay server-generated. @@ -164,7 +170,10 @@ billing_state, billing_postal_code, billing_country. expiration requires format or MM/YYYY. Optional timeout_ms is 1-30000 (default 10000). The API searches the page and descendant frames, including payment iframes. Fill is available for credential items and ready Link cards when advertised, not AgentCard. -Fill never submits forms. completed means fields were filled, not website acceptance. +Link cards do not expose aliases or support egress substitution. +Fill writes real values into the browser; unrestricted browser/CDP access can read them. +Fill never explicitly submits forms or clicks buttons, but input/change events may trigger site behavior. +completed means credentials were supplied, not website acceptance, login, or payment success. failed may leave partial writes; unknown quarantines the browser. Never automatically retry or fall back to aliases. Requests are not automatically retried. API validation errors (400/403/404/409) include HTTP status, recognized error codes, @@ -248,9 +257,7 @@ JSON cards := &cobra.Command{Use: "cards", Short: "Create immutable card requests at final checkout"} cards.AddCommand(newVaultCardCommand()) - paymentTokens := &cobra.Command{Use: "payment-tokens", Short: "Create merchant-bound Link payment tokens at final checkout"} - paymentTokens.AddCommand(newVaultPaymentTokenCommand()) - cmd.AddCommand(items, wallets, cards, paymentTokens, newVaultCredentialsCommand()) + cmd.AddCommand(items, wallets, cards, newVaultCredentialsCommand()) return cmd } @@ -274,13 +281,17 @@ func newVaultDeleteCommand(item bool) *cobra.Command { func newVaultCardCommand() *cobra.Command { cmd := &cobra.Command{Use: "create --provider --spec ''", Short: "Create an immutable card request and start approval", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, - Long: "Create a card after reaching final checkout. Link creation starts human approval; share the returned URL and retrieve the item until fill appears.\n" + vaultSpecHelp + vaultCardSpecHelp + vaultLinkPurchaseTypesHelp, + Long: "Create a card after reaching final checkout. For Link, Kernel inspects the checkout and internally selects a Link payment token or virtual card. Creation starts human approval; share the returned URL and retrieve the item until fill appears.\n" + vaultSpecHelp + vaultCardSpecHelp + vaultLinkPurchaseTypesHelp, Example: " kernel vaults cards create" + ` checkout order-1 \ - --provider agentcard --spec '{ + --provider link --spec '{ "wallet": "wallet-1", - "merchant": "Example Shop", + "browser_id": "browser-session-id", + "page_url": "https://shop.example/checkout", + "payment_method_id": "pm-1", "amount": 1234, - "currency": "usd" + "currency": "usd", + "merchant_name": "Example Shop", + "context": "Final checkout for one item totaling USD 12.34. This is a new purchase and not a retry of an uncertain payment." }'`, RunE: func(cmd *cobra.Command, args []string) error { spec, err := vaultSpecFromFlags(cmd) @@ -294,50 +305,6 @@ func newVaultCardCommand() *cobra.Command { return cmd } -func newVaultPaymentTokenCommand() *cobra.Command { - cmd := &cobra.Command{Use: "create --spec ''", Short: "Create an immutable Link payment token and start approval", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, - Long: "Create a payment token from the active final checkout. Kernel discovers Link support and the Stripe merchant binding from the browser.\n" + vaultSpecHelp + vaultPaymentTokenSpecHelp + vaultLinkPurchaseTypesHelp, - Example: ` kernel vaults payment-tokens create checkout order-1 --spec '{ - "wallet":"wallet-1", - "browser_id":"browser-session-id", - "page_url":"https://shop.example/checkout", - "payment_method_id":"pm-1", - "amount":1234, - "currency":"usd", - "context":"Final checkout for one item totaling USD 12.34. This is a new purchase and not a retry of an uncertain payment." - }'`, - RunE: func(cmd *cobra.Command, args []string) error { - spec, err := vaultPaymentTokenSpecFromFlags(cmd) - if err != nil { - return err - } - return getVaultsHandler(cmd).CreatePaymentToken(cmd.Context(), args[0], args[1], spec, vaultOutput(cmd)) - }} - cmd.Flags().String("spec", "", "Raw Link payment-token specification object (required)") - _ = cmd.MarkFlagRequired("spec") - addVaultJSONOutputFlag(cmd) - return cmd -} - -func vaultPaymentTokenSpecFromFlags(cmd *cobra.Command) (map[string]json.RawMessage, error) { - raw, _ := cmd.Flags().GetString("spec") - var spec map[string]json.RawMessage - if err := json.Unmarshal([]byte(raw), &spec); err != nil || spec == nil { - return nil, fmt.Errorf("--spec must be a JSON object") - } - if _, exists := spec["provider"]; exists { - return nil, fmt.Errorf("payment-token provider is Link; omit spec.provider") - } - if _, exists := spec["merchant_account_id"]; exists { - return nil, fmt.Errorf("omit merchant_account_id; Kernel discovers it from the checkout") - } - if vaultSpecHasSecrets(json.RawMessage(raw)) { - return nil, fmt.Errorf("--spec must not contain credentials or tokens") - } - spec["provider"] = json.RawMessage(`"link"`) - return spec, nil -} - func addVaultSpecFlags(cmd *cobra.Command) { cmd.Flags().String("provider", "", "Provider: link or agentcard (required)") cmd.Flags().String("spec", "", "Raw JSON specification object (required); see types and examples above") @@ -355,6 +322,11 @@ func vaultSpecFromFlags(cmd *cobra.Command) (map[string]json.RawMessage, error) if err := json.Unmarshal([]byte(raw), &spec); err != nil || spec == nil { return nil, fmt.Errorf("--spec must be a JSON object") } + if provider == "link" { + if _, exists := spec["merchant_account_id"]; exists { + return nil, fmt.Errorf("omit merchant_account_id; Kernel discovers it from the checkout") + } + } if value, ok := spec["provider"]; ok { var embedded string if err := json.Unmarshal(value, &embedded); err != nil || embedded != provider { diff --git a/cmd/vaults_fill.go b/cmd/vaults_fill.go index 04335e1c..73e3d19d 100644 --- a/cmd/vaults_fill.go +++ b/cmd/vaults_fill.go @@ -30,7 +30,7 @@ var vaultFillResultFields = vaultOutputFields{ "fields": vaultFieldsOf("index status error_code"), } -const vaultFillUncertain = "browser fields may have been written; inspect the browser and do not retry or fall back to aliases" +const vaultFillUncertain = "browser fields may have been written or a payment credential may have been supplied; inspect the browser and do not retry or fall back to aliases" var vaultFillErrorMessages = map[string]string{ "invalid_request": "check field names, formats, and browser parameters", diff --git a/cmd/vaults_fill_test.go b/cmd/vaults_fill_test.go index 063afc1e..15de198a 100644 --- a/cmd/vaults_fill_test.go +++ b/cmd/vaults_fill_test.go @@ -342,7 +342,7 @@ func TestVaultFillCLIValidationDoesNotReachAPI(t *testing.T) { assert.Zero(t, calls.Load()) } -func TestVaultPaymentTokenFillOutcomesHaveNoFieldBindings(t *testing.T) { +func TestVaultLinkWebMCPFillOutcomesHaveNoFieldBindings(t *testing.T) { for _, status := range []string{"completed", "failed", "unknown"} { t.Run(status, func(t *testing.T) { result, err := parseVaultFillResult(json.RawMessage(`{"type":"fill","status":"`+status+`","fields":[]}`), 0) @@ -353,12 +353,12 @@ func TestVaultPaymentTokenFillOutcomesHaveNoFieldBindings(t *testing.T) { } } -func TestVaultPaymentTokenFillOmitsFieldBindings(t *testing.T) { - const token = `{"id":"token-1","key":"order-token","type":"payment_token","spec":{"provider":"link","wallet":"wallet-1","browser_id":"browser-1","page_url":"https://shop.example/checkout","payment_method_id":"pm-1","amount":1234,"currency":"usd","context":"Final checkout purchase context."},"state":{"provider":"link","status":"ready"},"available_operations":[{"type":"fill","description":"Authenticate this checkout without submitting payment."}],"available_expansions":[]}` +func TestVaultLinkWebMCPCardFillOmitsFieldBindings(t *testing.T) { + const card = `{"id":"card-1","key":"order-card","type":"card","spec":{"provider":"link","wallet":"wallet-1","browser_id":"browser-1","page_url":"https://shop.example/checkout","payment_method_id":"pm-1","amount":1234,"currency":"usd","context":"Final checkout purchase context."},"state":{"provider":"link","status":"ready"},"available_operations":[{"type":"fill","description":"Authenticate this checkout through WebMCP without submitting payment; omit fields."}],"available_expansions":[]}` 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, token) + _, _ = io.WriteString(w, card) return } body, err := io.ReadAll(r.Body) @@ -367,10 +367,10 @@ func TestVaultPaymentTokenFillOmitsFieldBindings(t *testing.T) { _, _ = io.WriteString(w, `{"type":"fill","status":"completed","instruction":"Payment credentials are filled. Submit the checkout form when ready.","fields":[]}`) }) params := `{"browser_id":"browser-1","page_url":"https://shop.example/checkout"}` - out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-token", "fill", "--params", params, "-o", "json") + out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-card", "fill", "--params", params, "-o", "json") require.NoError(t, err) assert.Contains(t, out, `"instruction": "Payment credentials are filled. Submit the checkout form when ready."`) - _, human, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-token", "fill", "--params", params) + _, human, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-card", "fill", "--params", params) require.NoError(t, err) assert.Contains(t, human, "Submit the checkout form when ready") assert.NotContains(t, human, "Field index") diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index e2f3669e..dbccafba 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -55,11 +55,12 @@ require cancellation and a new item. Never replace an uncertain payment. type LinkCardSpec = { provider: "link"; wallet: string; // wallet item key + browser_id: string; // active vault-linked browser session ID + page_url: string; // exact final checkout page URL payment_method_id: string; // from wallets payment-methods - amount: number; // integer minor units; 1..500000 + amount: number; // integer minor units; 1..50000 currency: string; // three letters - merchant_name: string; // 1..255 characters - merchant_url: string; // URI + merchant_name: string; // approval-screen name; 1..255 characters context: string; // at least 100 characters line_items?: LinkLineItem[]; totals?: LinkTotal[]; @@ -76,7 +77,10 @@ type AgentCardCardSpec = { card_id?: string; // vc_...; otherwise chosen at approval }; -Permitted domains are provider-assigned, not configurable in the spec. +For Link, Kernel inspects WebMCP in the active checkout and internally selects a +Link payment token when definitely supported or a virtual card otherwise. Agents do +not provide merchant_account_id or choose the execution mode. The browser and page +bindings are immutable. Permitted domains are provider-assigned, not configurable. ` const vaultLinkPurchaseTypesHelp = ` @@ -98,27 +102,3 @@ type LinkTotal = { amount: number; // integer minor units }; ` - -const vaultPaymentTokenSpecHelp = ` -Create a Link payment token only after reaching final checkout. Kernel inspects the -vault-linked browser, reveals Link's agent controls, and binds the request to the -observed Stripe merchant. Do not inspect hidden controls or provide merchant_account_id. -If creation returns lpt_not_supported, create a card instead. No other error is a -fallback signal. Creation starts human approval and requests are immutable. -After approval, invoke fill; filling authenticates the checkout but does not submit it. - -type LinkPaymentTokenSpec = { - provider: "link"; - wallet: string; // connected wallet item key - browser_id: string; // active vault-linked browser session ID - page_url: string; // exact final checkout page URL - payment_method_id: string; // from wallets payment-methods - amount: number; // integer minor units; 1..500000 - currency: string; // three letters - context: string; // at least 100 characters - line_items?: LinkLineItem[]; - totals?: LinkTotal[]; - metadata?: Record; - expires_at?: number; // int64 -}; -` diff --git a/cmd/vaults_invoke_test.go b/cmd/vaults_invoke_test.go index 1f8a7b61..8459e8c5 100644 --- a/cmd/vaults_invoke_test.go +++ b/cmd/vaults_invoke_test.go @@ -87,7 +87,7 @@ func TestVaultInvokeArgumentsAndHelp(t *testing.T) { assert.NotNil(t, cmd.Flags().Lookup("params")) assert.Contains(t, cmd.Long, "failed/unknown exit nonzero") assert.Contains(t, cmd.Long, "there is no separate authorize operation") - assert.Contains(t, cmd.Long, "Payment tokens require") + assert.Contains(t, cmd.Long, "Link cards require") } func TestVaultInvokeOpensOnlyReturnedActionExplicitly(t *testing.T) { diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 861f2f86..52674569 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -265,7 +265,7 @@ func printVaultOperationHints(item *kernel.VaultItemUnion, vault, key, project s return nil } -type vaultPaymentTokenDisplay struct { +type vaultLinkCardDisplay struct { Spec struct { BrowserID string `json:"browser_id"` PageURL string `json:"page_url"` @@ -328,27 +328,22 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { if item.State.StatusReason != "" { rows = append(rows, []string{"Status reason", item.State.StatusReason}) } - if item.Type == "payment_token" { - var token vaultPaymentTokenDisplay - if json.Unmarshal([]byte(item.RawJSON()), &token) != nil { - return fmt.Errorf("invalid payment token response") - } - rows = append(rows, - []string{"Wallet key", token.Spec.Wallet}, - []string{"Amount (minor units)", fmt.Sprintf("%d %s", token.Spec.Amount, token.Spec.Currency)}, - []string{"Payment method ID", token.Spec.PaymentMethodID}, - []string{"Browser session ID", token.Spec.BrowserID}, - []string{"Checkout page", token.Spec.PageURL}, - ) - } if item.Type == "card" { - merchant := item.Spec.MerchantName + rows = append(rows, []string{"Wallet key", item.Spec.Wallet}, []string{"Amount (minor units)", fmt.Sprintf("%d %s", item.Spec.Amount, item.Spec.Currency)}) if item.Spec.Provider == "agentcard" { - merchant = item.Spec.Merchant + rows = append(rows, []string{"Merchant", item.Spec.Merchant}) } - rows = append(rows, []string{"Wallet key", item.Spec.Wallet}, []string{"Merchant", merchant}, []string{"Amount (minor units)", fmt.Sprintf("%d %s", item.Spec.Amount, item.Spec.Currency)}) if item.Spec.Provider == "link" { - rows = append(rows, []string{"Payment method ID", item.Spec.PaymentMethodID}) + rows = append(rows, []string{"Merchant", item.Spec.MerchantName}) + var card vaultLinkCardDisplay + if json.Unmarshal([]byte(item.RawJSON()), &card) != nil { + return fmt.Errorf("invalid Link card response") + } + rows = append(rows, + []string{"Payment method ID", card.Spec.PaymentMethodID}, + []string{"Browser session ID", card.Spec.BrowserID}, + []string{"Checkout page", card.Spec.PageURL}, + ) } } if item.State.JSON.Domains.Valid() { @@ -436,9 +431,7 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction if item.State.JSON.Aliases.Valid() { pterm.Info.Println("Aliases are non-secret checkout values. Use only in a browser created with this vault attached; ready does not mean paid.") } - pterm.Info.Println("Inspect items events for payment outcomes. Never retry automatically; if recovery permits abandonment, delete the card only after explicit user confirmation before creating a replacement.") - } else if item.Type == "payment_token" { - pterm.Info.Println("Inspect items events for payment outcomes. Fill authenticates checkout but does not submit payment. Do not retry failed or indeterminate payments.") + pterm.Info.Println("Inspect items events for payment outcomes. Fill supplies credentials but does not submit payment. Never retry automatically; if recovery permits abandonment, delete the card only after explicit user confirmation before creating a replacement.") } else { wallet := item.AsWallet() for _, expansion := range wallet.AvailableExpansions { @@ -468,7 +461,7 @@ func printVaultPaymentMethods(methods []kernel.VaultPaymentMethod) { 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, ", ")}) } PrintTableNoPad(rows, true) - pterm.Info.Println("Select an ID explicitly: try a Link payment token with payment_method_id first, then create a Link card only when the API returns lpt_not_supported. AgentCard uses card_id (or omit it for cardholder selection). Capabilities are advisory; missing means unknown, not ineligible.") + pterm.Info.Println("Select an ID explicitly. Link cards require payment_method_id; Kernel inspects checkout and selects the execution method. AgentCard uses card_id (or omit it for cardholder selection). Capabilities are advisory; missing means unknown, not ineligible.") } func printVaultEvents(events []kernel.VaultItemEvent, data []vaultJSON) { diff --git a/cmd/vaults_output_test.go b/cmd/vaults_output_test.go index 822b61b1..b8b13602 100644 --- a/cmd/vaults_output_test.go +++ b/cmd/vaults_output_test.go @@ -17,7 +17,7 @@ import ( const readyCardFixture = `{ "id":"card-id","key":"order-1","type":"card", - "spec":{"provider":"link","wallet":"wallet-1","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example","provider_secret":"SECRET_SPEC"}, + "spec":{"provider":"link","wallet":"wallet-1","browser_id":"browser-1","page_url":"https://shop.example/checkout","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","provider_secret":"SECRET_SPEC"}, "state":{"provider":"link","status":"ready","domains":["shop.example"],"aliases":{"number":"9999999999999999","cvc":"999","exp_month":"01","exp_year":"2099","secret":"SECRET_ALIAS"},"card_number":"SECRET_CARD","secret_enc":"SECRET_CIPHERTEXT"}, "available_operations":[],"available_expansions":[],"oauth_tokens":"SECRET_OAUTH" }` @@ -60,9 +60,9 @@ func TestVaultOutputAliasesPresenceAndRedaction(t *testing.T) { assert.Contains(t, out, `"aliases": null`) } -func TestVaultPaymentTokenHumanOutput(t *testing.T) { +func TestVaultLinkCardHumanOutput(t *testing.T) { var item kernel.VaultItemUnion - require.NoError(t, json.Unmarshal([]byte(paymentTokenFixture), &item)) + require.NoError(t, json.Unmarshal([]byte(requestedCardFixture), &item)) buf := capturePtermOutput(t) require.NoError(t, printVaultItem(&item, "")) for _, text := range []string{"Wallet key", "wallet-1", "Amount (minor units)", "1234 usd", "Payment method ID", "pm-1", "Browser session ID", "browser-1", "Checkout page", "https://shop.example/checkout", "does not submit payment"} { @@ -115,7 +115,7 @@ func TestVaultOutputPaymentMethodsAdvisoryUnknownVsFalse(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(body), &item)) buf := capturePtermOutput(t) require.NoError(t, printVaultItem(&item, "")) - for _, text := range []string{"Payment method ID", "pm-unknown", "unknown", "pm-ineligible", "false", "not_supported", "payment_method_id", "payment token", "advisory"} { + for _, text := range []string{"Payment method ID", "pm-unknown", "unknown", "pm-ineligible", "false", "not_supported", "payment_method_id", "execution method", "advisory"} { assert.Contains(t, buf.String(), text) } out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) diff --git a/cmd/vaults_secrets.go b/cmd/vaults_secrets.go index b0f1ef83..36f8410d 100644 --- a/cmd/vaults_secrets.go +++ b/cmd/vaults_secrets.go @@ -14,19 +14,16 @@ import ( // Do not wrap SDK errors here: response bodies and transport errors can echo // write-only credentials, and the root command unwraps SDK errors for display. -func vaultPaymentTokenError(err error) error { +func vaultCardError(err error) error { var apiErr *kernel.Error if errors.As(err, &apiErr) { var body struct { Code string `json:"code"` } if json.Unmarshal([]byte(apiErr.RawJSON()), &body) == nil { - if apiErr.StatusCode == 400 && body.Code == "lpt_not_supported" { - return fmt.Errorf("lpt_not_supported: this checkout does not support a Link payment token; create a card instead") - } discoveryFailure := false switch body.Code { - case "page_not_found", "ambiguous_page", "timeout": + case "ambiguous_page", "timeout": discoveryFailure = apiErr.StatusCode == 400 case "destination_denied": discoveryFailure = apiErr.StatusCode == 403 @@ -38,7 +35,7 @@ func vaultPaymentTokenError(err error) error { discoveryFailure = apiErr.StatusCode == 500 } if discoveryFailure { - return fmt.Errorf("%s: payment-token checkout discovery failed before a spend was created; correct the browser or page and retry", body.Code) + return fmt.Errorf("%s: Link checkout inspection failed before a card was created; correct the browser or page and retry", body.Code) } if apiErr.StatusCode == 409 && body.Code == "conflict" { return fmt.Errorf("vault conflict (HTTP 409); inspect current state and immutable bindings") diff --git a/cmd/vaults_spec_test.go b/cmd/vaults_spec_test.go index 4d0fc331..a2331be8 100644 --- a/cmd/vaults_spec_test.go +++ b/cmd/vaults_spec_test.go @@ -50,7 +50,7 @@ func TestVaultRawSpecForwarding(t *testing.T) { } } -func TestVaultPaymentTokenRequestKeepsMerchantBindingServerOwned(t *testing.T) { +func TestVaultLinkCardRequestKeepsMerchantBindingServerOwned(t *testing.T) { t.Setenv("KERNEL_PROJECT", "") client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodPut, r.Method) @@ -59,26 +59,26 @@ func TestVaultPaymentTokenRequestKeepsMerchantBindingServerOwned(t *testing.T) { Spec map[string]json.RawMessage `json:"spec"` } require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) - assert.Equal(t, "payment_token", body.Type) + assert.Equal(t, "card", body.Type) assert.JSONEq(t, `"link"`, string(body.Spec["provider"])) assert.NotContains(t, body.Spec, "merchant_account_id") w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, paymentTokenFixture) + _, _ = io.WriteString(w, requestedCardFixture) }) - spec := `{"wallet":"wallet-1","browser_id":"browser-1","page_url":"https://shop.example/checkout","payment_method_id":"pm-1","amount":1234,"currency":"usd","context":"Final checkout purchase context."}` - out, _, err := executeVaultCommand(t, client, "vaults", "payment-tokens", "create", "checkout", "order-token", "--spec", spec, "-o", "json") + spec := `{"wallet":"wallet-1","browser_id":"browser-1","page_url":"https://shop.example/checkout","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","context":"Final checkout purchase context."}` + out, _, err := executeVaultCommand(t, client, "vaults", "cards", "create", "checkout", "order-token", "--provider", "link", "--spec", spec, "-o", "json") require.NoError(t, err) - assert.Contains(t, out, `"type": "payment_token"`) + assert.Contains(t, out, `"type": "card"`) assert.Contains(t, out, `"browser_id": "browser-1"`) } -func TestVaultPaymentTokenFallbackOnlyForUnsupportedCheckout(t *testing.T) { +func TestVaultLinkCardCheckoutInspectionErrors(t *testing.T) { for _, tc := range []struct { status int code string want string }{ - {400, "lpt_not_supported", "create a card instead"}, + {400, "timeout", "correct the browser or page and retry"}, {409, "browser_unavailable", "correct the browser or page and retry"}, {409, "conflict", "vault conflict"}, {500, "provider_error", "outcome may be unresolved"}, @@ -88,12 +88,10 @@ func TestVaultPaymentTokenFallbackOnlyForUnsupportedCheckout(t *testing.T) { w.WriteHeader(tc.status) _, _ = io.WriteString(w, `{"code":"`+tc.code+`","message":"detail"}`) }) - spec := `{"wallet":"wallet-1","browser_id":"browser-1","page_url":"https://shop.example/checkout","payment_method_id":"pm-1","amount":1234,"currency":"usd","context":"Final checkout purchase context."}` - _, _, err := executeVaultCommand(t, client, "vaults", "payment-tokens", "create", "checkout", "order-token", "--spec", spec) + spec := `{"wallet":"wallet-1","browser_id":"browser-1","page_url":"https://shop.example/checkout","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","context":"Final checkout purchase context."}` + _, _, err := executeVaultCommand(t, client, "vaults", "cards", "create", "checkout", "order-token", "--provider", "link", "--spec", spec) require.ErrorContains(t, err, tc.want) - if tc.code != "lpt_not_supported" { - assert.NotContains(t, err.Error(), "create a card") - } + assert.NotContains(t, err.Error(), "create a card") if tc.code == "conflict" { assert.NotContains(t, err.Error(), "retry") } @@ -113,16 +111,16 @@ func TestVaultRawSpecValidationIsLeftToAPI(t *testing.T) { _, _ = io.WriteString(w, `{"code":"invalid_request","message":"wallet is required"}`) }) _, _, err := executeVaultCommand(t, client, "vaults", "cards", "create", "checkout", "order-1", "--provider", "link", "--spec", "{}") - require.ErrorContains(t, err, "invalid_request: wallet is required") + require.ErrorContains(t, err, "vault request rejected (HTTP 400)") } -func TestVaultPaymentTokenHelp(t *testing.T) { - cmd, _, err := newVaultsCommand().Find([]string{"payment-tokens", "create"}) +func TestVaultLinkCardHelp(t *testing.T) { + cmd, _, err := newVaultsCommand().Find([]string{"cards", "create"}) require.NoError(t, err) - for _, text := range []string{"browser_id", "page_url", "lpt_not_supported", "merchant_account_id", "immutable", "does not submit"} { + for _, text := range []string{"browser_id", "page_url", "merchant_account_id", "internally selects", "immutable"} { assert.Contains(t, cmd.Long, text) } - assert.Nil(t, cmd.Flags().Lookup("provider")) + assert.NotNil(t, cmd.Flags().Lookup("provider")) assert.NotNil(t, cmd.Flags().Lookup("spec")) } @@ -144,7 +142,7 @@ func TestVaultSpecHelpAndFlags(t *testing.T) { assert.NotContains(t, cmd.Long, "test: boolean") assert.NotContains(t, cmd.Long, "sandbox/live") if strings.HasPrefix(path, "cards") { - for _, field := range []string{"merchant_name:", "merchant:", "line_items?:", "metadata?:", "expires_at?:", "type LinkLineItem", "type LinkTotal"} { + for _, field := range []string{"browser_id:", "page_url:", "merchant_name:", "merchant:", "line_items?:", "metadata?:", "expires_at?:", "type LinkLineItem", "type LinkTotal"} { assert.Contains(t, cmd.Long, field) } } else { diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go index 60e50e30..baaa8e7e 100644 --- a/cmd/vaults_test.go +++ b/cmd/vaults_test.go @@ -22,10 +22,9 @@ import ( const linkWalletSpecFixture = `{"authorization":{"method":"oauth","client":{"type":"kernel_managed"}}}` const vaultFixture = `{"id":"vault-1","name":"checkout","created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-01T00:00:00Z"}` -const requestedCardFixture = `{"id":"item-1","key":"order-1","type":"card","spec":{"provider":"link","wallet":"wallet-1","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example","context":"Purchase description"},"state":{"provider":"link","status":"pending_authorization"},"action":{"name":"spend_approval","url":"https://example.com/approve"},"available_operations":[],"available_expansions":[]}` +const requestedCardFixture = `{"id":"item-1","key":"order-1","type":"card","spec":{"provider":"link","wallet":"wallet-1","browser_id":"browser-1","page_url":"https://shop.example/checkout","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","context":"Purchase description"},"state":{"provider":"link","status":"pending_authorization"},"action":{"name":"spend_approval","url":"https://example.com/approve"},"available_operations":[],"available_expansions":[]}` const connectedWalletFixture = `{"id":"wallet-id","key":"wallet-1","type":"wallet","description":"Reach checkout before creating a credential.","spec":{"provider":"link","authorization":{"method":"oauth","client":{"type":"kernel_managed"}}},"state":{"provider":"link","status":"connected"},"available_operations":[],"available_expansions":[{"type":"payment_methods","description":"Select a payment method explicitly."}]}` -const paymentTokenFixture = `{"id":"token-1","key":"order-token","type":"payment_token","description":"Approve this checkout-bound Link credential.","spec":{"provider":"link","wallet":"wallet-1","browser_id":"browser-1","page_url":"https://shop.example/checkout","payment_method_id":"pm-1","amount":1234,"currency":"usd","context":"Final checkout purchase context."},"state":{"provider":"link","status":"pending_authorization"},"action":{"name":"spend_approval","url":"https://example.com/approve"},"available_operations":[],"available_expansions":[]}` -const fillCardFixture = `{"id":"item-1","key":"order-1","type":"card","spec":{"provider":"link","wallet":"wallet-1","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example","context":"Purchase description"},"state":{"provider":"link","status":"ready"},"available_operations":[{"type":"fill","description":"Fill this approved credential without submitting payment."}],"available_expansions":[]}` +const fillCardFixture = `{"id":"item-1","key":"order-1","type":"card","spec":{"provider":"link","wallet":"wallet-1","browser_id":"browser-1","page_url":"https://shop.example/checkout","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","context":"Purchase description"},"state":{"provider":"link","status":"ready"},"available_operations":[{"type":"fill","description":"Fill this approved credential without submitting payment."}],"available_expansions":[]}` func vaultTestClient(t *testing.T, handler http.HandlerFunc) kernel.Client { t.Helper() @@ -54,7 +53,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", "payment-tokens create", "items invoke"} { + for _, path := range []string{"create", "list", "get", "delete", "items list", "items get", "items delete", "items events", "wallets create", "wallets payment-methods", "cards create", "items invoke"} { t.Run(path, func(t *testing.T) { cmd, remaining, err := newVaultsCommand().Find(strings.Fields(path)) require.NoError(t, err) @@ -73,7 +72,7 @@ func TestVaultCommandConstruction(t *testing.T) { cmd, _, err := rootCmd.Find([]string{"vaults", "items", "invoke"}) require.NoError(t, err) assert.False(t, isAuthExempt(cmd)) - for _, unsupported := range []string{"rename", "update", "items put", "items action", "wallets callback", "cards pay", "cards authorize", "cards update"} { + for _, unsupported := range []string{"rename", "update", "items put", "items action", "wallets callback", "cards pay", "cards authorize", "cards update", "payment-tokens create"} { cmd, remaining, _ := newVaultsCommand().Find(strings.Fields(unsupported)) assert.True(t, len(remaining) > 0 || cmd.RunE == nil, unsupported) } @@ -105,7 +104,6 @@ func TestVaultRequiredAndInvalidFlags(t *testing.T) { {"vaults wallets create checkout wallet-1 --provider unknown --spec {}", "--provider"}, {"vaults wallets create checkout wallet-1 --provider link", "required flag"}, {"vaults cards create checkout order-1 --provider link", "required flag"}, - {"vaults payment-tokens create checkout order-1", "required flag"}, {"vaults wallets create checkout wallet-1 --provider link --user-id usr_123", "--user-id"}, {"vaults wallets create checkout wallet-1 --provider agentcard --user-id wrong", "--user-id"}, {"vaults cards create checkout order-1", "required flag"}, @@ -141,7 +139,6 @@ func TestVaultCommandsWithoutProject(t *testing.T) { {[]string{"wallets", "create", "checkout", "wallet-1", "--provider", "link", "--spec", linkWalletSpecFixture}, connectedWalletFixture, 1}, {[]string{"wallets", "payment-methods", "checkout", "wallet-1"}, connectedWalletFixture, 1}, {append([]string{"cards", "create", "checkout", "order-1"}, linkCardArgs()...), requestedCardFixture, 1}, - {[]string{"payment-tokens", "create", "checkout", "order-token", "--spec", `{"wallet":"wallet-1","browser_id":"browser-1","page_url":"https://shop.example/checkout","payment_method_id":"pm-1","amount":1234,"currency":"usd","context":"Final checkout purchase context."}`}, paymentTokenFixture, 1}, } for _, tt := range tests { t.Run(strings.Join(tt.args[:min(2, len(tt.args))], " "), func(t *testing.T) { @@ -166,7 +163,7 @@ func TestVaultCommandsWithoutProject(t *testing.T) { } func linkCardArgs() []string { - return []string{"--provider", "link", "--spec", fmt.Sprintf(`{"wallet":"wallet-1","amount":1234,"currency":"USD","merchant_name":"Example Shop","payment_method_id":"pm-1","merchant_url":"https://shop.example","context":%q}`, strings.Repeat("Purchase purpose. ", 7))} + return []string{"--provider", "link", "--spec", fmt.Sprintf(`{"wallet":"wallet-1","browser_id":"browser-1","page_url":"https://shop.example/checkout","amount":1234,"currency":"USD","merchant_name":"Example Shop","payment_method_id":"pm-1","context":%q}`, strings.Repeat("Purchase purpose. ", 7))} } func TestVaultSpecValidation(t *testing.T) { @@ -277,7 +274,7 @@ func TestVaultCardRequestMapping(t *testing.T) { assert.JSONEq(t, `"card"`, string(body["type"])) assert.Len(t, body, 2) if provider == "link" { - assert.JSONEq(t, fmt.Sprintf(`{"provider":"link","wallet":"wallet-1","amount":1234,"currency":"USD","merchant_name":"Example Shop","merchant_url":"https://shop.example","payment_method_id":"pm-1","context":%q}`, strings.Repeat("Purchase purpose. ", 7)), string(body["spec"])) + assert.JSONEq(t, fmt.Sprintf(`{"provider":"link","wallet":"wallet-1","browser_id":"browser-1","page_url":"https://shop.example/checkout","amount":1234,"currency":"USD","merchant_name":"Example Shop","payment_method_id":"pm-1","context":%q}`, strings.Repeat("Purchase purpose. ", 7)), string(body["spec"])) } else { assert.JSONEq(t, `{"provider":"agentcard","wallet":"wallet-1","amount":1234,"currency":"usd","merchant":"Example Shop","card_id":"vc_chosen"}`, string(body["spec"])) } @@ -368,7 +365,6 @@ func TestVaultInvalidProjectErrors(t *testing.T) { {"wallets", "create", "checkout", "wallet-1", "--provider", "link", "--spec", linkWalletSpecFixture}, {"wallets", "payment-methods", "checkout", "wallet-1"}, append([]string{"cards", "create", "checkout", "order-1"}, linkCardArgs()...), - {"payment-tokens", "create", "checkout", "order-token", "--spec", `{"wallet":"wallet-1","browser_id":"browser","page_url":"https://shop.example","payment_method_id":"pm-1","amount":100,"currency":"usd","context":"Final checkout purchase context long enough for the payment request."}`}, {"items", "invoke", "checkout", "order-1", "fill", "--params", `{"browser_id":"browser","page_url":"https://shop.example"}`}, } for _, project := range []string{"doesntexist", "abcdefghijklmnopqrstuvwx"} { @@ -384,7 +380,7 @@ func TestVaultInvalidProjectErrors(t *testing.T) { }) out, human, err := executeVaultCommand(t, client, append([]string{"--project", project, "vaults"}, args...)...) require.Error(t, err) - if (args[0] == "wallets" || args[0] == "payment-tokens") && args[1] == "create" { + if (args[0] == "wallets" || args[0] == "cards") && args[1] == "create" { assert.Equal(t, "vault resource not found (HTTP 404)", err.Error()) } else if args[0] == "items" && args[1] == "invoke" { assert.Equal(t, "could not retrieve vault item; fill was not invoked", err.Error()) From 7cce4bfadbc242ea31cba42a8b47e00021fc1efd Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:24:59 +0000 Subject: [PATCH 4/7] Keep Link card output fill-only --- cmd/vaults_help.go | 2 +- cmd/vaults_output.go | 29 ++++++++++++++++++++++++----- cmd/vaults_output_test.go | 27 ++++++++++++++++++++++++--- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index dbccafba..48ed706d 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -58,7 +58,7 @@ type LinkCardSpec = { browser_id: string; // active vault-linked browser session ID page_url: string; // exact final checkout page URL payment_method_id: string; // from wallets payment-methods - amount: number; // integer minor units; 1..50000 + amount: number; // integer minor units; 1..500000 (virtual-card fallback: 1..50000) currency: string; // three letters merchant_name: string; // approval-screen name; 1..255 characters context: string; // at least 100 characters diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 52674569..620de551 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -125,9 +125,28 @@ func filterVaultJSON(raw json.RawMessage, fields vaultOutputFields) (json.RawMes } } var itemType string - if result["spec"] != nil && result["state"] != nil && json.Unmarshal(result["type"], &itemType) == nil && itemType == "credential" { - if err := preservePublicCredentialValues(object, result); err != nil { - return nil, err + if result["spec"] != nil && result["state"] != nil && json.Unmarshal(result["type"], &itemType) == nil { + if itemType == "credential" { + if err := preservePublicCredentialValues(object, result); err != nil { + return nil, err + } + } + if itemType == "card" { + var spec struct { + Provider string `json:"provider"` + } + if json.Unmarshal(result["spec"], &spec) == nil && spec.Provider == "link" { + var state vaultJSON + if json.Unmarshal(result["state"], &state) != nil { + return nil, fmt.Errorf("invalid vault response shape") + } + delete(state, "aliases") + filteredState, err := json.Marshal(state) + if err != nil { + return nil, err + } + result["state"] = filteredState + } } } return json.Marshal(result) @@ -355,7 +374,7 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { if !item.ExpiresAt.IsZero() { rows = append(rows, []string{"Expires At", util.FormatLocal(item.ExpiresAt)}) } - if item.State.JSON.Aliases.Valid() { + if item.Spec.Provider == "agentcard" && item.State.JSON.Aliases.Valid() { a := item.State.Aliases rows = append(rows, []string{"Checkout alias: number", a.Number}, []string{"Checkout alias: cvc", a.Cvc}, []string{"Checkout alias: exp_month", a.ExpMonth}, []string{"Checkout alias: exp_year", a.ExpYear}) } @@ -428,7 +447,7 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction for _, expansion := range card.AvailableExpansions { pterm.Printf("Available expansion: %s — %s\n", expansion.Type, expansion.Description) } - if item.State.JSON.Aliases.Valid() { + if item.Spec.Provider == "agentcard" && item.State.JSON.Aliases.Valid() { pterm.Info.Println("Aliases are non-secret checkout values. Use only in a browser created with this vault attached; ready does not mean paid.") } pterm.Info.Println("Inspect items events for payment outcomes. Fill supplies credentials but does not submit payment. Never retry automatically; if recovery permits abandonment, delete the card only after explicit user confirmation before creating a replacement.") diff --git a/cmd/vaults_output_test.go b/cmd/vaults_output_test.go index b8b13602..ee81042a 100644 --- a/cmd/vaults_output_test.go +++ b/cmd/vaults_output_test.go @@ -18,13 +18,20 @@ import ( const readyCardFixture = `{ "id":"card-id","key":"order-1","type":"card", "spec":{"provider":"link","wallet":"wallet-1","browser_id":"browser-1","page_url":"https://shop.example/checkout","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","provider_secret":"SECRET_SPEC"}, - "state":{"provider":"link","status":"ready","domains":["shop.example"],"aliases":{"number":"9999999999999999","cvc":"999","exp_month":"01","exp_year":"2099","secret":"SECRET_ALIAS"},"card_number":"SECRET_CARD","secret_enc":"SECRET_CIPHERTEXT"}, + "state":{"provider":"link","status":"ready","domains":["shop.example"],"card_number":"SECRET_CARD","secret_enc":"SECRET_CIPHERTEXT"}, + "available_operations":[],"available_expansions":[],"oauth_tokens":"SECRET_OAUTH" +}` + +const readyAgentCardFixture = `{ + "id":"card-id","key":"order-1","type":"card", + "spec":{"provider":"agentcard","wallet":"wallet-1","amount":1234,"currency":"usd","merchant":"Example Shop"}, + "state":{"provider":"agentcard","status":"ready","domains":["shop.example"],"aliases":{"number":"9999999999999999","cvc":"999","exp_month":"01","exp_year":"2099","secret":"SECRET_ALIAS"},"card_number":"SECRET_CARD","secret_enc":"SECRET_CIPHERTEXT"}, "available_operations":[],"available_expansions":[],"oauth_tokens":"SECRET_OAUTH" }` func TestVaultOutputAliasesPresenceAndRedaction(t *testing.T) { var item kernel.VaultItemUnion - require.NoError(t, json.Unmarshal([]byte(readyCardFixture), &item)) + require.NoError(t, json.Unmarshal([]byte(readyAgentCardFixture), &item)) buf := capturePtermOutput(t) require.NoError(t, printVaultItem(&item, "")) human := buf.String() @@ -57,7 +64,21 @@ func TestVaultOutputAliasesPresenceAndRedaction(t *testing.T) { require.NoError(t, printVaultItem(&item, "")) assert.NotContains(t, buf.String(), "Checkout alias") out = captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) - assert.Contains(t, out, `"aliases": null`) + assert.NotContains(t, out, `"aliases"`) +} + +func TestVaultOutputLinkHasNoAliases(t *testing.T) { + fixture := strings.Replace(readyCardFixture, `"status":"ready"`, `"status":"ready","aliases":{"number":"9999999999999999","cvc":"999","exp_month":"01","exp_year":"2099"}`, 1) + var item kernel.VaultItemUnion + require.NoError(t, json.Unmarshal([]byte(fixture), &item)) + buf := capturePtermOutput(t) + require.NoError(t, printVaultItem(&item, "")) + assert.NotContains(t, buf.String(), "9999999999999999") + assert.NotContains(t, buf.String(), "Checkout alias") + assert.NotContains(t, buf.String(), "Aliases are non-secret") + out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) + assert.NotContains(t, out, "aliases") + assert.NotContains(t, out, "9999999999999999") } func TestVaultLinkCardHumanOutput(t *testing.T) { From 8be820497d32d9f6369fbfbaad71ccae0a51d6f4 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:06:39 +0000 Subject: [PATCH 5/7] Drop fill result instruction field --- cmd/vaults_fill.go | 15 +++++---------- cmd/vaults_fill_test.go | 6 +++--- cmd/vaults_test.go | 4 ++-- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/cmd/vaults_fill.go b/cmd/vaults_fill.go index 73e3d19d..4f58e39a 100644 --- a/cmd/vaults_fill.go +++ b/cmd/vaults_fill.go @@ -13,10 +13,9 @@ import ( ) type vaultFillResult struct { - Type string `json:"type"` - Status string `json:"status"` - Instruction string `json:"instruction,omitempty"` - Fields []vaultFillFieldResult `json:"fields"` + Type string `json:"type"` + Status string `json:"status"` + Fields []vaultFillFieldResult `json:"fields"` } type vaultFillFieldResult struct { @@ -26,7 +25,7 @@ type vaultFillFieldResult struct { } var vaultFillResultFields = vaultOutputFields{ - "type": nil, "status": nil, "instruction": nil, + "type": nil, "status": nil, "fields": vaultFieldsOf("index status error_code"), } @@ -118,11 +117,7 @@ func (c VaultsCmd) fill(ctx context.Context, vault, key string, params *vaultFil PrintTableNoPad(rows, true) } if result.Status == "completed" { - if result.Instruction != "" { - pterm.Println(result.Instruction) - } else { - pterm.Println("Fields filled; this does not confirm website acceptance or form submission.") - } + pterm.Println("Fields filled; this does not confirm website acceptance or form submission.") } else { pterm.Println(vaultFillUncertain) } diff --git a/cmd/vaults_fill_test.go b/cmd/vaults_fill_test.go index 15de198a..ae27b1ea 100644 --- a/cmd/vaults_fill_test.go +++ b/cmd/vaults_fill_test.go @@ -364,15 +364,15 @@ func TestVaultLinkWebMCPCardFillOmitsFieldBindings(t *testing.T) { body, err := io.ReadAll(r.Body) require.NoError(t, err) assert.JSONEq(t, `{"type":"fill","browser_id":"browser-1","page_url":"https://shop.example/checkout","fields":[]}`, string(body)) - _, _ = io.WriteString(w, `{"type":"fill","status":"completed","instruction":"Payment credentials are filled. Submit the checkout form when ready.","fields":[]}`) + _, _ = io.WriteString(w, `{"type":"fill","status":"completed","fields":[]}`) }) params := `{"browser_id":"browser-1","page_url":"https://shop.example/checkout"}` out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-card", "fill", "--params", params, "-o", "json") require.NoError(t, err) - assert.Contains(t, out, `"instruction": "Payment credentials are filled. Submit the checkout form when ready."`) + assert.JSONEq(t, `{"type":"fill","status":"completed","fields":[]}`, out) _, human, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-card", "fill", "--params", params) require.NoError(t, err) - assert.Contains(t, human, "Submit the checkout form when ready") + assert.Contains(t, human, "Fields filled; this does not confirm website acceptance or form submission.") assert.NotContains(t, human, "Field index") } diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go index baaa8e7e..4345938d 100644 --- a/cmd/vaults_test.go +++ b/cmd/vaults_test.go @@ -314,7 +314,7 @@ func TestVaultInvokeRequiresAdvertisedFill(t *testing.T) { postCalls++ payload, _ := io.ReadAll(r.Body) assert.JSONEq(t, `{"type":"fill","browser_id":"browser","page_url":"https://shop.example","fields":[{"field":"number","selector":"#number"}]}`, string(payload)) - _, _ = io.WriteString(w, `{"type":"fill","status":"completed","instruction":"Payment credentials are filled. Submit the checkout form when ready.","fields":[{"index":0,"status":"filled"}]}`) + _, _ = io.WriteString(w, `{"type":"fill","status":"completed","fields":[{"index":0,"status":"filled"}]}`) }) spec := `{"browser_id":"browser","page_url":"https://shop.example","fields":[{"field":"number","selector":"#number"}]}` out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "fill", "--params", spec, "-o", "json") @@ -322,7 +322,7 @@ func TestVaultInvokeRequiresAdvertisedFill(t *testing.T) { if advertised { require.NoError(t, err) assert.Equal(t, 1, postCalls) - assert.Contains(t, out, `"instruction"`) + assert.JSONEq(t, `{"type":"fill","status":"completed","fields":[{"index":0,"status":"filled"}]}`, out) } else { require.ErrorContains(t, err, "not advertised in available_operations") assert.Zero(t, postCalls) From 92146ac48c46fd320c12eb7ac5070a4519fa17f7 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:38:02 +0000 Subject: [PATCH 6/7] Pin preview Go SDK and align vault help with Link card API - temporarily replace kernel-go-sdk with the preview build for Link checkout cards - send credential updates through the generic client while the preview SDK lacks the item update method - omit fields from selector-free fills instead of sending an empty array - point fill help at each item's advertised fill description and drop authorize wording - drop merchant_url from display output and map browser_unavailable fill errors --- cmd/vaults_commands.go | 7 +++---- cmd/vaults_credentials.go | 17 ++++++++++------- cmd/vaults_fill.go | 2 +- cmd/vaults_fill_test.go | 2 +- cmd/vaults_invoke_test.go | 3 ++- cmd/vaults_output.go | 4 ++-- go.mod | 2 ++ go.sum | 4 ++-- 8 files changed, 23 insertions(+), 18 deletions(-) diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 36e353a1..23dd4256 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -155,12 +155,11 @@ JSON output preserves returned public fields but omits unknown/opaque provider d invoke := &cobra.Command{Use: "invoke ", Short: "Invoke an operation advertised by an item", Args: cobra.ExactArgs(3), PreRunE: vaultPreRun, Long: `Retrieve the item and invoke only an operation listed in available_operations. collect returns a time-scoped URL for the full credential form without clearing values. -Payment credential creation starts approval; there is no separate authorize operation. -Read the operation description and follow any approval requirements before invoking. +Each operation's description lists the inputs that item needs; read it before invoking. fill requires --params JSON or --spec-file with browser_id (session ID, not name). Credentials require 1-32 ordered fields (field, selector). Link cards require an exact -page_url; include fields for a virtual-card fill and omit fields when the advertised operation -says Kernel will use WebMCP. Do not include type, values, or frame IDs. +page_url; include fields only when the item's advertised fill description asks for them. +Do not include type, values, or frame IDs. The vault must already be attached to the browser. page_url selects an existing page; fill never navigates. Credentials use declared field names, must omit format, and may omit page_url only when the API can resolve a unique page. TOTP codes stay server-generated. diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index 03b697c8..11e2389d 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -115,15 +115,18 @@ func (c VaultsCmd) saveCredential(ctx context.Context, vault, key string, data [ if version < 1 { return fmt.Errorf("--version must be positive") } - var spec kernel.CredentialVaultItemSpecUpdateParam - if json.Unmarshal(data, &spec) != nil { - return fmt.Errorf("invalid credential update spec") - } - request := kernel.CredentialVaultItemUpdateRequestParam{Type: "credential", Version: version, Spec: spec} + // The preview SDK for payment tokens omits the item update method; send the + // credential PATCH through the generic client until it is regenerated. + request := map[string]any{"type": "credential", "version": version, "spec": json.RawMessage(data)} if expectedID != "" { - request.ExpectedItemID = kernel.String(expectedID) + request["expected_item_id"] = expectedID + } + body, marshalErr := json.Marshal(request) + if marshalErr != nil { + return fmt.Errorf("invalid credential update spec") } - item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCredentialVaultItemUpdateRequest: &request}, option.WithMaxRetries(0)) + client := kernel.Client{Options: c.vaults.Items.Options} + err = client.Patch(ctx, fmt.Sprintf("vaults/%s/items/%s", vault, key), nil, &item, option.WithRequestBody("application/json", body), option.WithMaxRetries(0)) } else { var spec kernel.CredentialVaultItemSpecInputParam if json.Unmarshal(data, &spec) != nil || len(spec.Fields) == 0 { diff --git a/cmd/vaults_fill.go b/cmd/vaults_fill.go index 4f58e39a..5c0ffb6b 100644 --- a/cmd/vaults_fill.go +++ b/cmd/vaults_fill.go @@ -46,6 +46,7 @@ var vaultFillErrorMessages = map[string]string{ "field_unavailable": "a field has no usable stored value; inspect definitions and presence, and collect missing values", "conflict": "the item or browser is not ready; inspect readiness, binding, and unresolved prior operations", "destination_denied": "destination or browser vault binding is not authorized; check the bound browser and destination", + "browser_unavailable": "the browser session is not available; check that it is still running", "not_found": "check the vault, item, browser identifiers, and project", "execution_failed": "fill execution failed", } @@ -77,7 +78,6 @@ func (c VaultsCmd) fill(ctx context.Context, vault, key string, params *vaultFil request := kernel.FillVaultItemOperationRequestParam{ BrowserID: params.BrowserID, Type: kernel.FillVaultItemOperationRequestTypeFill, - Fields: make([]kernel.VaultFillFieldParam, 0, len(params.Fields)), } if params.PageURL != "" { request.PageURL = kernel.Opt(params.PageURL) diff --git a/cmd/vaults_fill_test.go b/cmd/vaults_fill_test.go index ae27b1ea..285825c5 100644 --- a/cmd/vaults_fill_test.go +++ b/cmd/vaults_fill_test.go @@ -363,7 +363,7 @@ func TestVaultLinkWebMCPCardFillOmitsFieldBindings(t *testing.T) { } body, err := io.ReadAll(r.Body) require.NoError(t, err) - assert.JSONEq(t, `{"type":"fill","browser_id":"browser-1","page_url":"https://shop.example/checkout","fields":[]}`, string(body)) + assert.JSONEq(t, `{"type":"fill","browser_id":"browser-1","page_url":"https://shop.example/checkout"}`, string(body)) _, _ = io.WriteString(w, `{"type":"fill","status":"completed","fields":[]}`) }) params := `{"browser_id":"browser-1","page_url":"https://shop.example/checkout"}` diff --git a/cmd/vaults_invoke_test.go b/cmd/vaults_invoke_test.go index 8459e8c5..9b896657 100644 --- a/cmd/vaults_invoke_test.go +++ b/cmd/vaults_invoke_test.go @@ -86,7 +86,8 @@ func TestVaultInvokeArgumentsAndHelp(t *testing.T) { assert.NotNil(t, cmd.Flags().Lookup("open")) assert.NotNil(t, cmd.Flags().Lookup("params")) assert.Contains(t, cmd.Long, "failed/unknown exit nonzero") - assert.Contains(t, cmd.Long, "there is no separate authorize operation") + assert.NotContains(t, cmd.Long, "authorize") + assert.Contains(t, cmd.Long, "available_operations") assert.Contains(t, cmd.Long, "Link cards require") } diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 620de551..7e93fb4b 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -39,7 +39,7 @@ var vaultItemFields = vaultOutputFields{ "expanded": {"payment_methods": vaultMethodFields}, "spec": { "provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil, - "browser_id": nil, "page_url": nil, "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, + "browser_id": nil, "page_url": nil, "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "context": nil, "expires_at": nil, "description": nil, "fields": vaultFieldsOf("name label type required sensitive"), "provider_config": vaultFieldsOf("id name"), @@ -111,7 +111,7 @@ func filterVaultJSON(raw json.RawMessage, fields vaultOutputFields) (json.RawMes continue } if value, ok := object[key]; ok { - if key == "url" || key == "approval_url" || key == "merchant_url" || key == "merchant_origin" || key == "image_url" || key == "product_url" { + if key == "url" || key == "approval_url" || key == "merchant_origin" || key == "image_url" || key == "product_url" { var address string if json.Unmarshal(value, &address) != nil || !vaultDisplayURL(address) { continue diff --git a/go.mod b/go.mod index 8655cc53..94167dcb 100644 --- a/go.mod +++ b/go.mod @@ -61,3 +61,5 @@ require ( golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect ) + +replace github.com/kernel/kernel-go-sdk => github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20260924200253-a235f00476bf diff --git a/go.sum b/go.sum index dbb5d098..79a669ee 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,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.112.0 h1:WCWHRtHQs/z4Q8cwZ/uS/HnxZZi6roge+VYai5d73ic= -github.com/kernel/kernel-go-sdk v0.112.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20260924200253-a235f00476bf h1:hUjPD8jrTIZOo9cVz3nYUxV+D5Hc6Z57/EzX5ZXMFQM= +github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20260924200253-a235f00476bf/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= From e8908429dfe0accb043d7e687fe53d1da26e5529 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:45:05 +0000 Subject: [PATCH 7/7] Apply display-safe URL filter to checkout page_url --- cmd/vaults_output.go | 2 +- cmd/vaults_output_test.go | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 7e93fb4b..895ffc3c 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -111,7 +111,7 @@ func filterVaultJSON(raw json.RawMessage, fields vaultOutputFields) (json.RawMes continue } if value, ok := object[key]; ok { - if key == "url" || key == "approval_url" || key == "merchant_origin" || key == "image_url" || key == "product_url" { + if key == "url" || key == "approval_url" || key == "page_url" || key == "merchant_origin" || key == "image_url" || key == "product_url" { var address string if json.Unmarshal(value, &address) != nil || !vaultDisplayURL(address) { continue diff --git a/cmd/vaults_output_test.go b/cmd/vaults_output_test.go index ee81042a..ede34ba1 100644 --- a/cmd/vaults_output_test.go +++ b/cmd/vaults_output_test.go @@ -214,6 +214,19 @@ func TestVaultURLsWithSecretsAreWithheld(t *testing.T) { } } +func TestVaultCheckoutPageURLsWithSecretsAreWithheld(t *testing.T) { + for _, address := range []string{"https://user:SECRET@shop.example/checkout", "https://shop.example/checkout?password=SECRET"} { + var item kernel.VaultItemUnion + require.NoError(t, json.Unmarshal([]byte(strings.Replace(readyCardFixture, "https://shop.example/checkout", address, 1)), &item)) + buf := capturePtermOutput(t) + require.NoError(t, printVaultItem(&item, "")) + assert.NotContains(t, buf.String(), "SECRET") + out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) + assert.NotContains(t, out, "SECRET") + assert.NotContains(t, out, "page_url") + } +} + func TestVaultGetCancellation(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel()