diff --git a/cmd/vaults.go b/cmd/vaults.go index 6250948c..d6748371 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -2,11 +2,11 @@ package cmd import ( "context" + "encoding/json" "fmt" "net/http" "net/url" "regexp" - "strings" "time" "github.com/kernel/cli/pkg/interactive" @@ -136,13 +136,13 @@ func (c VaultsCmd) ListItems(ctx context.Context, vault, output string) error { pterm.Info.Println("No vault items found") return nil } - rows := pterm.TableData{{"Key", "Type", "Provider", "Status", "Action"}} + rows := pterm.TableData{{"Key", "Type", "Provider", "Status", "Action", "Description"}} for _, item := range *items { actions, err := effectiveVaultItemActions(&item) if err != nil { return err } - rows = append(rows, []string{item.Key, item.Type, item.Spec.Provider, item.State.Status, util.OrDash(actions.RequiredAction)}) + rows = append(rows, []string{item.Key, item.Type, item.Spec.Provider, item.State.Status, util.OrDash(actions.RequiredAction), vaultItemDescription(&item)}) } PrintTableNoPad(rows, true) return nil @@ -187,23 +187,30 @@ 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, 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) Invoke(ctx context.Context, vault, key, operation, output string, open bool) error { - if strings.TrimSpace(operation) == "" { - return fmt.Errorf("operation must not be empty") +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, spec map[string]json.RawMessage, output string) error { + if operation != "fill" { + return fmt.Errorf("operation must be fill") } item, err := c.vaults.Items.Get(ctx, key, kernel.VaultItemGetParams{IDOrName: vault}, option.WithMaxRetries(0)) if err != nil { @@ -229,11 +236,21 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation, output str if !available { return fmt.Errorf("operation %q is not advertised in available_operations; inspect the item", operation) } - item, err = c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, Type: kernel.VaultItemPerformOperationParamsType(operation)}, option.WithMaxRetries(0)) + body := make(map[string]any, len(spec)+1) + body["type"] = operation + for name, value := range spec { + body[name] = value + } + encoded, err := json.Marshal(body) + if err != nil { + return err + } + var result vaultFillOperationResult + _, err = c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, Type: kernel.VaultItemPerformOperationParamsType("fill")}, option.WithRequestBody("application/json", encoded), option.WithResponseBodyInto(&result), option.WithMaxRetries(0)) if err != nil { return util.CleanedUpSdkError{Err: err} } - return c.showItem(item, output, open) + return printVaultOperationResult(result, output) } func (c VaultsCmd) Events(ctx context.Context, vault, key, after string, wait int64, output string) error { diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index b68c0497..c671e295 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -59,12 +59,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 . Use only returned - non-secret aliases in that browser. Inspect items get/events for the outcome. +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. Credential PUT 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. @@ -131,14 +132,19 @@ JSON output preserves returned public fields but omits unknown/opaque provider d itemEvents.Flags().String("after", "", "Return events after this event ID (use the last ID from the previous response)") itemEvents.Flags().Int64("wait", 0, "Long-poll once for new events (0-60 seconds)") addVaultJSONOutputFlag(itemEvents) - 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.\nRead its description with items get before invoking; follow any approval requirements.\nThe API determines availability regardless of item type, provider, or state.\nRequests are not automatically retried. The updated item may contain a required user action.\nThe current API accepts only {\"type\":\"authorize\"}; there are no operation parameters or --spec flag.", - Example: " kernel vaults items get checkout order-1\n kernel vaults items invoke checkout order-1 authorize", + invoke := &cobra.Command{Use: "invoke fill --spec ''", Short: "Fill an approved credential without submitting payment", Args: cobra.ExactArgs(3), PreRunE: vaultPreRun, + Long: "Retrieve the item and invoke fill only when listed in available_operations.\n--spec is the fill parameters object without type: browser_id, page_url, and card fields when required.\nPayment tokens discover their hidden provider field and reject caller-supplied fields.\nA completed fill supplies credentials but never clicks Pay or submits the purchase.\nFailed and unknown outcomes are not automatically retried.", + Example: ` kernel vaults items get checkout order-1 + kernel vaults items invoke checkout order-1 fill --spec '{"browser_id":"browser-session-id","page_url":"https://shop.example/checkout","fields":[{"field":"number","selector":"#card-number"}]}'`, RunE: func(cmd *cobra.Command, args []string) error { - open, _ := cmd.Flags().GetBool("open") - return getVaultsHandler(cmd).Invoke(cmd.Context(), args[0], args[1], args[2], vaultOutput(cmd), open) + spec, err := vaultOperationSpec(cmd) + if err != nil { + return err + } + return getVaultsHandler(cmd).Invoke(cmd.Context(), args[0], args[1], args[2], spec, vaultOutput(cmd)) }} - invoke.Flags().Bool("open", false, "Open a returned HTTPS action URL in your browser") + invoke.Flags().String("spec", "", "Raw fill parameters JSON without the type field (required)") + _ = invoke.MarkFlagRequired("spec") addVaultJSONOutputFlag(invoke) items.AddCommand(itemList, itemGet, itemEvents, invoke, newVaultDeleteCommand(true)) @@ -179,9 +185,11 @@ JSON output preserves returned public fields but omits unknown/opaque provider d 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) + 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) return cmd } @@ -203,21 +211,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 to retry a failed, timed-out, rejected, or indeterminate payment. -` + 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", @@ -229,13 +226,72 @@ Never reconfigure to retry a failed, timed-out, rejected, or indeterminate payme 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 vaultOperationSpec(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["type"]; exists { + return nil, fmt.Errorf("omit type from --spec; it comes from the operation argument") + } + if vaultSpecHasSecrets(json.RawMessage(raw)) { + return nil, fmt.Errorf("--spec must not contain credential values or tokens") + } + 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_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 fa185098..05cc1fcd 100644 --- a/cmd/vaults_invoke_test.go +++ b/cmd/vaults_invoke_test.go @@ -1,184 +1,58 @@ package cmd import ( - "context" - "fmt" "io" "net/http" - "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestVaultInvokeUsesAdvertisedTypeAcrossItems(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") - require.NoError(t, err) - assert.Equal(t, 2, calls) - assert.JSONEq(t, fixture, out) - assert.Empty(t, human) - }) - } -} +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 TestVaultInvokeRejectsUnadvertisedOperation(t *testing.T) { +func TestVaultInvokeRejectsUnsupportedOrUnadvertisedOperation(t *testing.T) { t.Setenv("KERNEL_PROJECT", "") - calls := 0 client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { - calls++ assert.Equal(t, http.MethodGet, r.Method) 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`) - assert.Equal(t, 1, calls) -} - -func TestVaultInvokeGetFailureDoesNotPostOrRetry(t *testing.T) { - t.Setenv("KERNEL_PROJECT", "") - for _, status := range []int{403, 404, 429, 500} { - t.Run(fmt.Sprint(status), func(t *testing.T) { - calls := 0 - client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { - calls++ - assert.Equal(t, http.MethodGet, r.Method) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _, _ = io.WriteString(w, `{"code":"item_unavailable","message":"Item unavailable"}`) - }) - _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "authorize") - require.ErrorContains(t, err, "item_unavailable: Item unavailable") - assert.Equal(t, 1, calls) - }) - } + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "authorize", "--spec", `{}`) + require.ErrorContains(t, err, "operation must be fill") + _, _, err = executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "fill", "--spec", `{"browser_id":"browser","page_url":"https://shop.example"}`) + require.ErrorContains(t, err, `operation "fill" is not advertised`) } func TestVaultInvokeArgumentsAndHelp(t *testing.T) { - t.Setenv("KERNEL_PROJECT", "") - client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid input reached API") }) + client := vaultTestClient(t, func(http.ResponseWriter, *http.Request) { t.Error("invalid input reached API") }) for _, args := range [][]string{ {"checkout", "order-1"}, - {"checkout", "order-1", ""}, - {"checkout", "order-1", "authorize", "extra"}, - {"checkout", "order-1", "authorize", "--spec", "{}"}, + {"checkout", "order-1", "fill"}, + {"checkout", "order-1", "fill", "--spec", `{"type":"fill"}`}, + {"checkout", "order-1", "fill", "--spec", `{"link_pay_token":"secret"}`}, } { _, _, err := executeVaultCommand(t, client, append([]string{"vaults", "items", "invoke"}, args...)...) require.Error(t, err) } cmd, _, err := newVaultsCommand().Find([]string{"items", "invoke"}) require.NoError(t, err) - assert.Nil(t, cmd.Flags().Lookup("spec")) - assert.NotNil(t, cmd.Flags().Lookup("open")) - assert.Contains(t, cmd.Long, `{"type":"authorize"}`) + assert.NotNil(t, cmd.Flags().Lookup("spec")) + assert.Nil(t, cmd.Flags().Lookup("open")) + assert.Contains(t, cmd.Long, "never clicks Pay") assert.Contains(t, cmd.Long, "available_operations") } -func TestVaultInvokeOpensOnlyReturnedActionExplicitly(t *testing.T) { - for _, open := range []bool{false, true} { - t.Run(fmt.Sprint(open), func(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) - }) - 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", "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) - } else { - assert.Empty(t, opened) - } - }) - } -} - -func TestVaultGetOperationHintUsesExplicitProject(t *testing.T) { +func TestVaultGetFillHintUsesExplicitProject(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) + _, _ = io.WriteString(w, fillCardFixture) }) _, 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.Contains(t, human, "Available operation: fill") + assert.Contains(t, human, "Invoke: kernel vaults items invoke --project=chosen-project -- checkout order-1 fill") 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) - }) - } - } -} diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 3262007c..1f2ad73b 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -32,13 +32,13 @@ var vaultMethodFields = vaultOutputFields{ "capabilities": {"single_use_card": vaultFieldsOf("eligible reasons")}, } var vaultItemFields = vaultOutputFields{ - "id": nil, "key": nil, "type": nil, "created_at": nil, "updated_at": nil, "expires_at": nil, + "id": nil, "key": nil, "type": nil, "description": nil, "created_at": nil, "updated_at": nil, "expires_at": nil, "available_operations": vaultOperationFields, "available_expansions": vaultOperationFields, "action": vaultFieldsOf("name url"), "expanded": {"payment_methods": vaultMethodFields}, "spec": { - "provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil, + "provider": nil, "wallet": nil, "user_id": nil, "browser_id": nil, "page_url": nil, "payment_method_id": nil, "card_id": nil, "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, "context": nil, "expires_at": nil, "provider_config": vaultFieldsOf("id name"), @@ -56,6 +56,22 @@ var vaultItemFields = vaultOutputFields{ "authorization": vaultFieldsOf("id status psp merchant amount amount_cents currency created_at expires_at approval_url browser_id reason psp_error_code expected_cents actual_cents amount_authority amount_verified charged_amount_cents charged_currency charged_kind replay_attempted replay_status replay_delivered"), }, } + +type vaultFillOperationResult struct { + Type string `json:"type"` + Status string `json:"status"` + Instruction string `json:"instruction,omitempty"` + Fields []struct { + Index int `json:"index"` + Status string `json:"status"` + ErrorCode string `json:"error_code,omitempty"` + } `json:"fields"` +} + +var vaultFillResultFields = vaultOutputFields{ + "type": nil, "status": nil, "instruction": nil, + "fields": vaultFieldsOf("index status error_code"), +} var vaultEventFields = vaultOutputFields{ "id": nil, "name": nil, "created_at": nil, "browser_id": nil, "data": vaultFieldsOf("reason operation status authorization_id vault_session_id request_kind outcome_reason provider_status provider_code provider_request_id provider_payment_status provider_error_type provider_error_code provider_decline_code provider_error_param provider_http_status provider_response_bytes provider_latency_ms payment_intent_id payment_method_id checkout_session_id replay_attempted replay_delivered charged_amount_cents charged_currency charged_kind expected_cents actual_cents currency actual_currency intent_status amount_verified psp_error_code"), @@ -98,7 +114,7 @@ func filterVaultJSON(raw json.RawMessage, fields vaultOutputFields) (json.RawMes result := make(vaultJSON) for key, children := range fields { if value, ok := object[key]; ok { - if key == "url" || key == "approval_url" || key == "merchant_url" || key == "image_url" || key == "product_url" { + if key == "url" || key == "approval_url" || key == "merchant_url" || key == "page_url" || key == "image_url" || key == "product_url" { var address string if json.Unmarshal(value, &address) != nil || !vaultDisplayURL(address) { continue @@ -139,6 +155,37 @@ func printVaultJSON(value any) error { return nil } +func printVaultOperationResult(value any, output string) error { + raw, err := json.Marshal(value) + if err != nil { + return err + } + filtered, err := filterVaultJSON(raw, vaultFillResultFields) + if err != nil { + return err + } + if output == "json" { + return printVaultJSON(filtered) + } + var result vaultFillOperationResult + if json.Unmarshal(filtered, &result) != nil || result.Status == "" { + return fmt.Errorf("invalid vault operation response") + } + rows := pterm.TableData{{"Property", "Value"}, {"Status", result.Status}} + if result.Instruction != "" { + rows = append(rows, []string{"Next", result.Instruction}) + } + PrintTableNoPad(rows, true) + for _, field := range result.Fields { + if field.ErrorCode == "" { + pterm.Printf("Field %d: %s\n", field.Index, field.Status) + } else { + pterm.Printf("Field %d: %s (%s)\n", field.Index, field.Status, field.ErrorCode) + } + } + return nil +} + func printVault(v *kernel.Vault, output string) error { if output == "json" { raw, err := filterVaultJSON(json.RawMessage(v.RawJSON()), vaultFields) @@ -195,11 +242,30 @@ func printVaultOperationHints(item *kernel.VaultItemUnion, vault, key, project s prefix += " --project=" + vaultShellArgument(project) } for _, op := range actions.Operations { - pterm.Printf("Invoke: %s -- %s %s %s\n", prefix, vaultShellArgument(vault), vaultShellArgument(key), vaultShellArgument(op.Type)) + pterm.Printf("Invoke: %s -- %s %s %s --spec ''\n", prefix, vaultShellArgument(vault), vaultShellArgument(key), vaultShellArgument(op.Type)) } 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 { @@ -221,6 +287,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 == "wallet" { configID, configName := item.Spec.ProviderConfig.ID, item.Spec.ProviderConfig.Name if item.Spec.Provider == "link" { @@ -246,6 +315,18 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { if item.Spec.Provider == "link" { rows = append(rows, []string{"Payment method ID", item.Spec.PaymentMethodID}) } + } else 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.State.JSON.Domains.Valid() { rows = append(rows, []string{"Permitted domains (provider-assigned)", strings.Join(item.State.Domains, ", ")}) @@ -304,6 +385,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. Do not retry failed, timed-out, rejected, or indeterminate payments.") + } 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 { diff --git a/cmd/vaults_output_test.go b/cmd/vaults_output_test.go index fe757e1a..b3d02762 100644 --- a/cmd/vaults_output_test.go +++ b/cmd/vaults_output_test.go @@ -45,11 +45,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, "")) @@ -58,6 +59,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(`{ 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 24803447..f5be1073 100644 --- a/cmd/vaults_secrets.go +++ b/cmd/vaults_secrets.go @@ -14,6 +14,37 @@ 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) + } + } + } + return vaultCredentialError(err) +} + func vaultCredentialError(err error) error { var apiErr *kernel.Error if errors.As(err, &apiErr) { @@ -71,7 +102,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 b20f2078..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.Empty(t, 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 8584a189..4d1577c0 100644 --- a/cmd/vaults_test.go +++ b/cmd/vaults_test.go @@ -22,8 +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":"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","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":[]}` func vaultTestClient(t *testing.T, handler http.HandlerFunc) kernel.Client { t.Helper() @@ -52,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", "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 +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"} { + 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 +104,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 +140,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 +171,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,88 +264,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)) - if operation == "create" { - assert.JSONEq(t, `"card"`, string(body["type"])) - assert.Len(t, body, 2) - } else { - assert.Len(t, body, 1) - } - 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", "--spec", 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) { @@ -354,20 +342,20 @@ 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", "--spec", 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()) }) } } @@ -381,8 +369,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", "--spec", `{"browser_id":"browser","page_url":"https://shop.example"}`}, } for _, project := range []string{"doesntexist", "abcdefghijklmnopqrstuvwx"} { for _, args := range commands { @@ -397,7 +385,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[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 { 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 cd0f1f63..f03ec596 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", "--spec", `{"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, `{"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 {