diff --git a/cmd/vaults.go b/cmd/vaults.go index 20fb1cf4..dbb95b33 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -188,16 +188,10 @@ 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 vaultCardError(err) } return c.showItem(item, output, false) } @@ -243,6 +237,12 @@ 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 == "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) } request := kernel.VaultItemPerformOperationParams{IDOrName: vault} @@ -254,8 +254,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 d725e66c..23dd4256 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -59,7 +59,7 @@ Use wallet and card item types for credit cards and payment checkout instead. User credential flow: 1. Create a vault per end user and create a browser with --vault . -2. Navigate to a sensitive form and define its fields in natural top-to-bottom order with credentials create --spec-file; that array order controls the user-facing collection form. +2. Navigate to a sensitive form and define its fields with credentials create --spec-file. 3. Present the returned collection URL to the user. Poll items get --wait 60 for ready. 4. Use items invoke fill --spec-file with browser_id and field selectors. Use credentials update --version for edits, or items invoke collect to reopen the form. @@ -73,16 +73,18 @@ 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 ; attachment is required for fill. - Ready Link cards use only advertised fill with --params for browser checkout. - Link cards do not expose aliases or support egress substitution. - AgentCard-only checkout aliases support egress substitution with checkout hold, - approval, and replay; they are 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. 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 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. @@ -153,10 +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. -authorize sends {"type":"authorize"} for payment authorization. -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. +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 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. @@ -169,23 +172,18 @@ Fill is available for credential items and ready Link cards when advertised, not 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 fields were filled, not website acceptance, login, or payment success. +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, and corrective guidance; no fields were written by that request. Inspect and correct the cause before deciding on a new fill. Transport loss remains an uncertain outcome. prepare_checkout requires checkout.browser_id, checkout.merchant_origin (canonical HTTPS -origin of the top-level merchant page, not a processor iframe), and checkout.environment -(production, sandbox, or shared). Optional checkout.psp selects the tokenization processor: -square, braintree, worldpay, bambora, or mercado_pago. Omit psp for Square; non-Square -processors require multi-processor preparation enablement. Use production or sandbox for -square, braintree and worldpay; shared for bambora and mercado_pago. Shared endpoints do not -establish test mode; merchant credentials determine it. +origin of the top-level merchant page), and checkout.environment (production or sandbox). 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' @@ -256,8 +254,8 @@ JSON addVaultJSONOutputFlag(methods) wallets.AddCommand(walletCreate, methods) - cards := &cobra.Command{Use: "cards", Short: "Configure card requests"} - cards.AddCommand(newVaultCardCommand(false), newVaultCardCommand(true)) + cards := &cobra.Command{Use: "cards", Short: "Create immutable card requests at final checkout"} + cards.AddCommand(newVaultCardCommand()) cmd.AddCommand(items, wallets, cards, newVaultCredentialsCommand()) return cmd } @@ -280,34 +278,26 @@ 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 \ - --provider agentcard --spec '{ +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. 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 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) 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) @@ -331,6 +321,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_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 b2d787d8..5c0ffb6b 100644 --- a/cmd/vaults_fill.go +++ b/cmd/vaults_fill.go @@ -29,7 +29,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", @@ -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) @@ -109,11 +109,13 @@ 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.") } else { @@ -142,6 +144,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 868f2048..285825c5 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,40 @@ 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 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) + require.NoError(t, err) + require.Equal(t, status, result.Status) + require.Empty(t, result.Fields) + }) + } +} + +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, card) + 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"}`, string(body)) + _, _ = 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.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, "Fields filled; this does not confirm website acceptance or form submission.") + assert.NotContains(t, human, "Field index") } func TestVaultFillSingleFieldAndHint(t *testing.T) { @@ -375,23 +397,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..48ed706d 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -48,14 +48,19 @@ 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 + 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..500000 (virtual-card fallback: 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[]; @@ -72,6 +77,13 @@ type AgentCardCardSpec = { card_id?: string; // vc_...; otherwise chosen at approval }; +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 = ` type LinkLineItem = { name: string; quantity?: number; // integer >= 1 @@ -89,6 +101,4 @@ type LinkTotal = { display_text: string; amount: number; // integer minor units }; - -Permitted domains are provider-assigned, not configurable in the spec. ` diff --git a/cmd/vaults_invoke_test.go b/cmd/vaults_invoke_test.go index 82adf78a..9b896657 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,9 @@ 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.NotContains(t, cmd.Long, "authorize") assert.Contains(t, cmd.Long, "available_operations") + assert.Contains(t, cmd.Long, "Link cards require") } func TestVaultInvokeOpensOnlyReturnedActionExplicitly(t *testing.T) { @@ -106,25 +97,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 +117,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 9d1c60bd..895ffc3c 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, "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 == "page_url" || key == "merchant_origin" || key == "image_url" || key == "product_url" { var address string if json.Unmarshal(value, &address) != nil || !vaultDisplayURL(address) { continue @@ -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) @@ -265,6 +284,25 @@ func printVaultOperationHints(item *kernel.VaultItemUnion, vault, key, project s return nil } +type vaultLinkCardDisplay 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 { @@ -286,6 +324,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") @@ -307,13 +348,21 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { rows = append(rows, []string{"Status reason", item.State.StatusReason}) } 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() { @@ -325,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}) } @@ -398,10 +447,10 @@ 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. Never retry automatically; if recovery permits abandonment, delete the card only after explicit user confirmation before creating a replacement.") + 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 { @@ -431,7 +480,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. 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 034f05fe..ede34ba1 100644 --- a/cmd/vaults_output_test.go +++ b/cmd/vaults_output_test.go @@ -15,92 +15,79 @@ import ( "github.com/stretchr/testify/require" ) -const readyAgentCardFixture = `{ +const readyCardFixture = `{ "id":"card-id","key":"order-1","type":"card", - "spec":{"provider":"agentcard","wallet":"wallet-1","amount":1234,"currency":"usd","merchant":"Example Shop","provider_secret":"SECRET_SPEC"}, - "state":{"provider":"agentcard","status":"ready","aliases":{"number":"9999999999999999","cvc":"999","exp_month":"01","exp_year":"2099","secret":"SECRET_ALIAS"},"card_number":"SECRET_CARD","secret_enc":"SECRET_CIPHERTEXT"}, + "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"],"card_number":"SECRET_CARD","secret_enc":"SECRET_CIPHERTEXT"}, "available_operations":[],"available_expansions":[],"oauth_tokens":"SECRET_OAUTH" }` -const readyLinkCardFixture = `{ +const readyAgentCardFixture = `{ "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"}, - "state":{"provider":"link","status":"ready","domains":["shop.example"],"masks":{"brand":"visa","last4":"1234"},"card_number":"SECRET_CARD","secret_enc":"SECRET_CIPHERTEXT"}, - "available_operations":[{"type":"fill","description":"Fill checkout fields."}],"available_expansions":[],"oauth_tokens":"SECRET_OAUTH" + "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 TestVaultOutputAgentCardAliasesPresenceAndRedaction(t *testing.T) { +func TestVaultOutputAliasesPresenceAndRedaction(t *testing.T) { var item kernel.VaultItemUnion require.NoError(t, json.Unmarshal([]byte(readyAgentCardFixture), &item)) buf := capturePtermOutput(t) require.NoError(t, printVaultItem(&item, "")) human := buf.String() - for _, value := range []string{"9999999999999999", "999", "01", "2099"} { - assert.Contains(t, human, value) - } - for _, field := range []string{"number", "cvc", "exp_month", "exp_year"} { - assert.Contains(t, human, "Checkout alias: "+field) - } + assert.Contains(t, human, "9999999999999999") + assert.Contains(t, human, "Checkout alias: cvc") + assert.Contains(t, human, "Permitted domains (provider-assigned)") + assert.Contains(t, human, "shop.example") assert.Contains(t, human, "ready does not mean paid") assert.Contains(t, human, "Never retry automatically") assert.Contains(t, human, "explicit user confirmation") assert.NotContains(t, human, "SECRET") out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) assert.NotContains(t, out, "SECRET") - var decoded struct { - State vaultJSON `json:"state"` - } - require.NoError(t, json.Unmarshal([]byte(out), &decoded)) - assert.JSONEq(t, `{"number":"9999999999999999","cvc":"999","exp_month":"01","exp_year":"2099"}`, string(decoded.State["aliases"])) + assert.Contains(t, out, "9999999999999999") assert.NotContains(t, out, "authorization") assert.NotContains(t, out, "expires_at") - withoutAliases := `{"id":"card-id","key":"order-1","type":"card","spec":{"provider":"agentcard"},"state":{"provider":"agentcard","status":"requested"},"available_operations":[]}` - require.NoError(t, json.Unmarshal([]byte(withoutAliases), &item)) + require.NoError(t, json.Unmarshal([]byte(requestedCardFixture), &item)) buf.Reset() require.NoError(t, printVaultItem(&item, "")) assert.NotContains(t, buf.String(), "Checkout alias") + 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(withoutAliases, `"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, "")) 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) { - for _, fixture := range []string{requestedCardFixture, readyLinkCardFixture} { - var item kernel.VaultItemUnion - require.NoError(t, json.Unmarshal([]byte(fixture), &item)) - t.Run(item.State.Status, func(t *testing.T) { - buf := capturePtermOutput(t) - require.NoError(t, printVaultItem(&item, "")) - assert.NotContains(t, buf.String(), "alias") - assert.NotContains(t, buf.String(), "SECRET") - operation := "authorize" - if item.State.Status == "ready" { - operation = "fill" - assert.Contains(t, buf.String(), "Permitted domains (provider-assigned)") - assert.Contains(t, buf.String(), "shop.example") - } - assert.Contains(t, buf.String(), "Available operation: "+operation) - out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) - assert.NotContains(t, out, "aliases") - assert.NotContains(t, out, "SECRET") - var decoded struct { - State vaultJSON `json:"state"` - } - require.NoError(t, json.Unmarshal([]byte(out), &decoded)) - assert.JSONEq(t, fmt.Sprintf("%q", item.State.Status), string(decoded.State["status"])) - if item.State.Status == "ready" { - assert.JSONEq(t, `["shop.example"]`, string(decoded.State["domains"])) - assert.JSONEq(t, `{"brand":"visa","last4":"1234"}`, string(decoded.State["masks"])) - } - }) + 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) { + var item kernel.VaultItemUnion + 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"} { + assert.Contains(t, buf.String(), text) } } @@ -149,7 +136,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", "execution method", "advisory"} { assert.Contains(t, buf.String(), text) } out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) @@ -227,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() 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..36f8410d 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 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 { + discoveryFailure := false + switch body.Code { + case "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: 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") + } + } + } + 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 b5b31c99..a2331be8 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,54 @@ func TestVaultRawSpecForwarding(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) + 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, "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, requestedCardFixture) + }) + 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": "card"`) + assert.Contains(t, out, `"browser_id": "browser-1"`) +} + +func TestVaultLinkCardCheckoutInspectionErrors(t *testing.T) { + for _, tc := range []struct { + status int + code string + want string + }{ + {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"}, + } { + 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","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) + 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) { @@ -68,11 +111,21 @@ 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 TestVaultLinkCardHelp(t *testing.T) { + cmd, _, err := newVaultsCommand().Find([]string{"cards", "create"}) + require.NoError(t, err) + for _, text := range []string{"browser_id", "page_url", "merchant_account_id", "internally selects", "immutable"} { + assert.Contains(t, cmd.Long, text) + } + assert.NotNil(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) @@ -89,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 14942156..4345938d 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","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 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() @@ -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", "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", "payment-tokens create"} { cmd, remaining, _ := newVaultsCommand().Find(strings.Fields(unsupported)) assert.True(t, len(remaining) > 0 || cmd.RunE == nil, unsupported) } @@ -103,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 cards update checkout order-1 --spec {}", "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 +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}, - {append([]string{"cards", "update", "checkout", "order-1"}, linkCardArgs()...), requestedCardFixture, 1}, - {[]string{"items", "invoke", "checkout", "order-1", "authorize"}, requestedCardFixture, 2}, } for _, tt := range tests { t.Run(strings.Join(tt.args[:min(2, len(tt.args))], " "), func(t *testing.T) { @@ -165,13 +163,13 @@ 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) { 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 +262,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","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"])) } - 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","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.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) + } + }) } } -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 +340,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 +365,7 @@ 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"}, + {"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 +380,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] == "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()) } 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 { 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=