diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 0e18536..a71b081 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -12,7 +12,7 @@ brew install onkernel/tap/kernel Install the following tools: -- Go 1.22+ ( https://go.dev/doc/install ) +- Go 1.25+ ( https://go.dev/doc/install ); the required version is recorded in `go.mod` - [Goreleaser Pro](https://goreleaser.com/install/#pro) - **IMPORTANT: You must install goreleaser-pro, not the standard version, as this is required for our release process** - [chglog](https://github.com/goreleaser/chglog) diff --git a/README.md b/README.md index 3192dd5..7fc71fc 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ Commands with JSON output support: - **Proxies**: `create`, `list`, `get`, `update`, `check` - **API Keys**: `create`, `list`, `get`, `update`, `rotate` - **Auth Connections**: `timeline` +- **Vaults**: `create`, `list`, `get`, `items list/get/events/invoke`, `wallets create/payment-methods`, `cards create/update` (display-safe public fields only) - **Projects**: `update` - **Org**: `limits get/set` - **Apps**: `list`, `history` @@ -223,6 +224,7 @@ Commands with JSON output support: - `--proxy-mode direct|default` - Egress mode instead of a selected proxy: `direct` for no proxy regardless of stealth, `default` for the stealth-derived default (Kernel's stealth proxy with `--stealth`, direct egress otherwise). Omit all proxy flags to get the default. - `--name ` - Optional unique name for the session (used to find it later by name; can be changed with `browsers update --name`) - `--tag ` - Set a tag on the session, repeatable; up to 50 pairs + - `--vault ` - Attach a project-owned vault at creation (repeatable, max 20). Uses the API's effective project unless `--project` or `KERNEL_PROJECT` selects one. Cannot be combined with pool flags, even with `--yes`; vault bindings cannot be added to existing sessions. - `--pool-id ` - Acquire a browser from the specified pool (mutually exclusive with --pool-name; ignores other session flags). `--name`/`--tag` still apply to the acquired session. - `--pool-name ` - Acquire a browser from the pool name (mutually exclusive with --pool-id; ignores other session flags) - `--telemetry=all` - Enable telemetry for all categories @@ -266,6 +268,174 @@ Commands with JSON output support: - `-s, --silent` - Suppress progress output - _Note: redirects are followed automatically by Chromium._ +### Vaults + +Vault commands **prepare and observe payment credentials; they do not submit merchant payments**. +Vault names, item keys, and project ownership are immutable. Optionally select a project with +`--project ` or `KERNEL_PROJECT`; otherwise, the API resolves the project from your +credentials and its defaults (the default project for org-wide credentials, not all projects). +Ownership is assigned from that scope, not a `project_id` body field. Project-scoped credentials +cannot switch projects. + +#### Command reference + +| Command | Purpose / flags | +| --- | --- | +| `kernel vaults create --name ` | Create or retrieve the vault with that immutable name | +| `kernel vaults list` | `--limit 1..100` (default 20), `--offset`; JSON includes `vaults` and optional `next_offset` | +| `kernel vaults get ` | Get by ID or name | +| `kernel vaults delete ` | Invalidate the vault and all its items; `--yes` skips confirmation | +| `kernel vaults wallets create --provider link\|agentcard --spec ''` | Connect/enroll a wallet using its provider's spec; `--open` opens a returned HTTPS action URL | +| `kernel vaults wallets payment-methods ` | Fetch advertised live payment methods; JSON is the item with `expanded.payment_methods` | +| `kernel vaults cards create --provider link\|agentcard --spec ''` | Create a card request; never implicitly authorize Link | +| `kernel vaults cards update --provider link\|agentcard --spec ''` | Replace the full card spec; the API enforces state/provider constraints | +| `kernel vaults items list ` | List item keys, types, providers, status, and required actions | +| `kernel vaults items get ` | Inspect state/actions/returned aliases and copyable operation commands; `--wait 0..60`, `--expand payment_methods`, `--open` | +| `kernel vaults items invoke ` | GET the item, then POST an advertised operation; optional `--open` opens a returned HTTPS action | +| `kernel vaults items events ` | Read ordered audit events; `--after `, `--wait 0..60` | +| `kernel vaults items delete ` | Invalidate an item; `--yes` skips confirmation | + +`` accepts an ID or name. `` is the immutable item key within that vault, not +its generated item ID. Names and keys use letters, digits, dots, underscores, and hyphens +(1–255 characters; not `.` or `..`). All commands except delete support `-o json`. +JSON preserves field presence and API-returned aliases, while omitting unknown fields, +opaque metadata, and unrecognized event data. Human output labels aliases as non-secret +checkout values and distinguishes card readiness from checkout authorization/payment outcomes. +Action and approval URLs print in full on separate lines, without table truncation. +API failures use the CLI's standard error formatter, preserving the API's code and message. +`vaults delete` and `vaults items delete` treat HTTP 404 as success and print +`Deleted or not found`, whether the missing object is the project, vault, or item. +Other API errors still return a nonzero exit status. + +**Provider specifications:** wallet creation and card creation/update require `--provider` +and `--spec ''`. Supply only the spec object, not a `{type, spec}` envelope. The command +sets the item type and injects `provider`; if JSON also contains `provider`, it must match. +Other values are forwarded unchanged, including optional fields, without defaults or normalization. +The API validates the provider-specific schema. Each command's `--help` includes its raw +TypeScript-style types, which must stay in sync with the [API spec](https://api.onkernel.com/spec.yaml). + +- **Link wallet:** supply `authorization: {method: "oauth", client: {type: "kernel_managed"}}`. +- **AgentCard wallet:** use `{}` to enroll, or supply `user_id` for an already enrolled user. +- **Link card:** include the required fields shown in help. Optional `line_items`, `totals`, + `metadata`, and `expires_at` are supported through JSON. +- **AgentCard card:** uses `merchant`, not Link's `merchant_name`. Its optional `card_id` selects + a vaulted card; otherwise the cardholder selects one at approval. + +`cards update` replaces the entire spec, so omitted optional details are removed. Permitted +checkout domains remain provider-assigned. Neither command submits a merchant payment. + +#### Link checkout preparation + +1. Create/select a vault in the effective project. Connect the wallet in the provider's UI: + + ```bash + kernel vaults create --name checkout + kernel vaults wallets create checkout wallet-1 --provider link \ + --spec '{"authorization":{"method":"oauth","client":{"type":"kernel_managed"}}}' --open + kernel vaults items get checkout wallet-1 --wait 60 + ``` + +2. Once connected, list methods and explicitly choose a returned ID: + + ```bash + kernel vaults wallets payment-methods checkout wallet-1 + # Equivalent: kernel vaults items get checkout wallet-1 --expand payment_methods + kernel vaults cards create checkout order-1 --provider link --spec '{ + "wallet": "wallet-1", + "payment_method_id": "", + "amount": 1234, + "currency": "usd", + "merchant_name": "Example Shop", + "merchant_url": "https://shop.example", + "context": "Purchase the selected office supplies from Example Shop for the approved order, with a total spending limit of 1234 minor currency units." + }' + ``` + +3. After explicit user approval, authorize **only if the item advertises it**. Follow the + returned approval action, then observe: + + ```bash + kernel vaults items get checkout order-1 + kernel vaults items invoke checkout order-1 authorize --open + kernel vaults items get checkout order-1 --wait 60 + ``` + +4. When ready, attach the same vault to a new browser. Use only the returned + `state.aliases` values in that browser's checkout and respect returned permitted domains: + + ```bash + kernel browsers create --vault checkout + ``` + +5. Observe outcomes independently of merchant checkout submission: + + ```bash + kernel vaults items get checkout order-1 + kernel vaults items events checkout order-1 + kernel vaults items events checkout order-1 --after --wait 60 + ``` + +#### AgentCard checkout preparation + +For a separate AgentCard flow, create a vault and complete the wallet enrollment action: + +```bash +kernel vaults create --name agentcard-checkout +kernel vaults wallets create agentcard-checkout wallet-1 --provider agentcard --spec '{}' --open +kernel vaults items get agentcard-checkout wallet-1 --wait 60 +``` + +Once the wallet is connected, create the card request: + +```bash +kernel vaults cards create agentcard-checkout order-1 --provider agentcard --spec '{ + "wallet": "wallet-1", + "merchant": "Example Shop", + "amount": 1234, + "currency": "usd" +}' +kernel browsers create --vault agentcard-checkout +``` + +AgentCard authorizes at checkout and does not currently advertise `authorize`. To select a +vaulted card in advance, inspect `wallets payment-methods` and include its ID as `card_id` in the +card spec. Otherwise, the cardholder selects a card at approval. A reusable card being +`ready` does not mean the last payment succeeded. + +#### Invoking item operations + +`items get` displays every `available_operations` entry's type and description, plus a +copyable `items invoke` command retaining the selected project. Read the description and +follow its approval requirements before invoking. Required user actions (OAuth, enrollment, +MFA, spend approval) appear separately; they are not operations to invoke through this endpoint. + +`items invoke` fetches the item again and calls +`POST /vaults/{id_or_name}/items/{key}/operations` only if the requested operation is still +advertised. The API controls availability; the CLI has no provider/type/state-specific +operation checks. The response is the updated item, possibly with a required user action. + +The current [API spec](https://api.onkernel.com/spec.yaml) accepts only +`{"type":"authorize"}` and forbids extra fields. There is no operation `--spec` flag; +wallet/card `--spec` flags remain unchanged. New parameterless operations can be invoked by +name when the API advertises them, without adding CLI subcommands. + +#### Expansions, updates, and lifecycle + +`--expand` takes a value, such as `--expand payment_methods`; it is not a boolean switch. +Request only expansions advertised in `available_expansions`. Add `-o json` to read the +returned `expanded.payment_methods` directly. Unavailable expansions return an API error. + +Use `cards update --provider --spec ''` to replace the entire +card spec when the API permits it. Include optional fields you want to retain; the CLI +does not merge the new JSON with the existing spec. + +Waits are single bounded observations, not readiness guarantees or payment retries. Pending +state is returned as-is. Requests are not automatically retried by the vault commands. +**Never retry failed, timed-out, rejected, or indeterminate payments.** Inspect state/events +and reconcile the outcome instead. Do not pass card data, OAuth codes/tokens, ciphertext, +provider secrets, or sensitive provider responses to the CLI. Complete collection, OAuth, +and approval actions through the provider's returned URL/UI; no callback-code command exists. + ### Browser Pools - `kernel browser-pools list` - List browser pools diff --git a/cmd/browser_vaults.go b/cmd/browser_vaults.go new file mode 100644 index 0000000..a6848dd --- /dev/null +++ b/cmd/browser_vaults.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "fmt" + + kernel "github.com/kernel/kernel-go-sdk" +) + +func buildBrowserVaults(values []string) ([]kernel.VaultReferenceParam, error) { + if len(values) > 20 { + return nil, fmt.Errorf("at most 20 --vault references may be attached") + } + var refs []kernel.VaultReferenceParam + seen := make(map[string]bool, len(values)) + for _, value := range values { + if err := validateVaultName(value, "--vault"); err != nil { + return nil, err + } + if seen[value] { + return nil, fmt.Errorf("duplicate --vault reference") + } + seen[value] = true + ref := kernel.VaultReferenceParam{} + if cuidRegex.MatchString(value) { + ref.ID = kernel.Opt(value) + } else { + ref.Name = kernel.Opt(value) + } + refs = append(refs, ref) + } + return refs, nil +} diff --git a/cmd/browser_vaults_test.go b/cmd/browser_vaults_test.go new file mode 100644 index 0000000..2d53d54 --- /dev/null +++ b/cmd/browser_vaults_test.go @@ -0,0 +1,103 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/kernel/cli/pkg/util" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildBrowserVaults(t *testing.T) { + const id = "abcdefghijklmnopqrstuvwx" + refs, err := buildBrowserVaults([]string{id, "checkout"}) + require.NoError(t, err) + require.Len(t, refs, 2) + assert.Equal(t, id, refs[0].ID.Value) + assert.False(t, refs[0].Name.Valid()) + assert.Equal(t, "checkout", refs[1].Name.Value) + assert.False(t, refs[1].ID.Valid()) + for _, values := range [][]string{{""}, {" "}, {"../checkout"}, {".."}, {"checkout", "checkout"}, make([]string, 21)} { + _, err := buildBrowserVaults(values) + require.Error(t, err) + } + refs, err = buildBrowserVaults(nil) + require.NoError(t, err) + body, err := json.Marshal(kernel.BrowserNewParams{Vaults: refs}) + require.NoError(t, err) + assert.NotContains(t, string(body), "vaults") + assert.NotNil(t, browsersCreateCmd.Flags().Lookup("vault")) + assert.Nil(t, browsersUpdateCmd.Flags().Lookup("vault")) + assert.False(t, poolLeaseAllowedFlags()["vault"]) +} + +func browserVaultTestCommand(client kernel.Client) *cobra.Command { + cmd := &cobra.Command{Use: "create"} + cmd.Flags().String("project", "", "") + cmd.Flags().StringArray("vault", nil, "") + cmd.Flags().String("pool-id", "", "") + cmd.Flags().String("pool-name", "", "") + cmd.Flags().Bool("yes", false, "") + addJSONOutputFlag(cmd) + cmd.SetContext(context.WithValue(context.Background(), util.KernelClientKey, client)) + return cmd +} + +func TestBrowserVaultPoolAndReferenceValidation(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid attachment reached API") }) + for _, flags := range [][]string{ + {"--vault", "checkout", "--pool-id", "pool-1", "--yes"}, + {"--vault", "checkout", "--pool-name", "pool", "--yes"}, + {"--vault="}, + } { + cmd := browserVaultTestCommand(client) + require.NoError(t, cmd.ParseFlags(flags)) + err := runBrowsersCreate(cmd, nil) + require.Error(t, err) + } +} + +func TestBrowserCreateVaultRequestAndReturnedAttachments(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + const body = `{"session_id":"browser-1","cdp_ws_url":"ws://example.test/cdp","vaults":[{"id":"vault-1","name":"checkout"}]}` + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/browsers", r.URL.Path) + assert.Empty(t, r.Header.Get("X-Kernel-Project")) + payload, _ := io.ReadAll(r.Body) + assert.JSONEq(t, `{"vaults":[{"name":"checkout"}]}`, string(payload)) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, body) + }) + for _, output := range []string{"", "json"} { + cmd := browserVaultTestCommand(client) + require.NoError(t, cmd.Flags().Set("vault", "checkout")) + require.NoError(t, cmd.Flags().Set("output", output)) + buf := capturePtermOutput(t) + out := captureStdout(t, func() { require.NoError(t, runBrowsersCreate(cmd, nil)) }) + if output == "json" { + assert.JSONEq(t, body, out) + } else { + assert.Contains(t, buf.String(), "Attached vault ID") + assert.Contains(t, buf.String(), "vault-1") + assert.Contains(t, buf.String(), "checkout") + } + } +} + +func TestBrowserCreateInvalidVaultNeverCallsSDK(t *testing.T) { + b := BrowsersCmd{browsers: &FakeBrowsersService{NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) { + t.Fatal("invalid vault reference should not reach SDK") + return nil, nil + }}} + require.Error(t, b.Create(context.Background(), BrowsersCreateInput{Vaults: []string{strings.Repeat("x", 256)}})) +} diff --git a/cmd/browsers.go b/cmd/browsers.go index caf2cfe..8096b87 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -377,6 +377,7 @@ type BrowsersCreateInput struct { PrivateHosts []string StartURL string Extensions []string + Vaults []string Viewport string Telemetry string TelemetryExport string @@ -564,7 +565,11 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { if err := validateStartURLFlag(in.StartURL); err != nil { return err } - params := kernel.BrowserNewParams{} + vaults, err := buildBrowserVaults(in.Vaults) + if err != nil { + return err + } + params := kernel.BrowserNewParams{Vaults: vaults} if in.TimeoutSeconds > 0 { params.TimeoutSeconds = kernel.Opt(int64(in.TimeoutSeconds)) } @@ -705,6 +710,13 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } printBrowserSessionResult(browser.SessionID, browser.CdpWsURL, browser.BrowserLiveViewURL, browser.Profile, browser.ProfileSaveChanges, browser.StartURL, browser.Name, browser.Tags) + if len(browser.Vaults) > 0 { + rows := pterm.TableData{{"Attached vault ID", "Name"}} + for _, vault := range browser.Vaults { + rows = append(rows, []string{vault.ID, vault.Name}) + } + PrintTableNoPad(rows, true) + } if in.Telemetry != "" || in.TelemetryExport != "" { printTelemetrySummary(browser.Telemetry) } @@ -1590,7 +1602,7 @@ func (b BrowsersCmd) ReplaysStop(ctx context.Context, in BrowsersReplaysStopInpu if err != nil { return util.CleanedUpSdkError{Err: err} } - err = b.replays.Stop(ctx, in.ReplayID, kernel.BrowserReplayStopParams{ID: br.SessionID}) + err = b.replays.Stop(ctx, in.ReplayID, kernel.BrowserReplayStopParams{IDOrName: br.SessionID}) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -1599,7 +1611,7 @@ func (b BrowsersCmd) ReplaysStop(ctx context.Context, in BrowsersReplaysStopInpu } func (b BrowsersCmd) ReplaysDownload(ctx context.Context, in BrowsersReplaysDownloadInput) error { - res, err := b.replays.Download(ctx, in.ReplayID, kernel.BrowserReplayDownloadParams{ID: in.Identifier}) + res, err := b.replays.Download(ctx, in.ReplayID, kernel.BrowserReplayDownloadParams{IDOrName: in.Identifier}) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -1905,7 +1917,7 @@ func (b BrowsersCmd) ProcessKill(ctx context.Context, in BrowsersProcessKillInpu if err != nil { return util.CleanedUpSdkError{Err: err} } - params := kernel.BrowserProcessKillParams{ID: br.SessionID, Signal: kernel.BrowserProcessKillParamsSignal(in.Signal)} + params := kernel.BrowserProcessKillParams{IDOrName: br.SessionID, Signal: kernel.BrowserProcessKillParamsSignal(in.Signal)} _, err = b.process.Kill(ctx, in.ProcessID, params) if err != nil { return util.CleanedUpSdkError{Err: err} @@ -1923,7 +1935,7 @@ func (b BrowsersCmd) ProcessStatus(ctx context.Context, in BrowsersProcessStatus if err != nil { return util.CleanedUpSdkError{Err: err} } - res, err := b.process.Status(ctx, in.ProcessID, kernel.BrowserProcessStatusParams{ID: br.SessionID}) + res, err := b.process.Status(ctx, in.ProcessID, kernel.BrowserProcessStatusParams{IDOrName: br.SessionID}) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -1941,7 +1953,7 @@ func (b BrowsersCmd) ProcessStdin(ctx context.Context, in BrowsersProcessStdinIn if err != nil { return util.CleanedUpSdkError{Err: err} } - _, err = b.process.Stdin(ctx, in.ProcessID, kernel.BrowserProcessStdinParams{ID: br.SessionID, DataB64: in.DataB64}) + _, err = b.process.Stdin(ctx, in.ProcessID, kernel.BrowserProcessStdinParams{IDOrName: br.SessionID, DataB64: in.DataB64}) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -1958,7 +1970,7 @@ func (b BrowsersCmd) ProcessStdoutStream(ctx context.Context, in BrowsersProcess if err != nil { return util.CleanedUpSdkError{Err: err} } - stream := b.process.StdoutStreamStreaming(ctx, in.ProcessID, kernel.BrowserProcessStdoutStreamParams{ID: br.SessionID}) + stream := b.process.StdoutStreamStreaming(ctx, in.ProcessID, kernel.BrowserProcessStdoutStreamParams{IDOrName: br.SessionID}) if stream == nil { pterm.Error.Println("failed to open stdout stream") return nil @@ -1992,7 +2004,7 @@ func (b BrowsersCmd) ProcessResize(ctx context.Context, in BrowsersProcessResize if err != nil { return util.CleanedUpSdkError{Err: err} } - params := kernel.BrowserProcessResizeParams{ID: br.SessionID, Cols: in.Cols, Rows: in.Rows} + params := kernel.BrowserProcessResizeParams{IDOrName: br.SessionID, Cols: in.Cols, Rows: in.Rows} _, err = b.process.Resize(ctx, in.ProcessID, params) if err != nil { return util.CleanedUpSdkError{Err: err} @@ -2041,7 +2053,7 @@ func (b BrowsersCmd) FSWatchStop(ctx context.Context, in BrowsersFSWatchStopInpu if err != nil { return util.CleanedUpSdkError{Err: err} } - err = b.fsWatch.Stop(ctx, in.WatchID, kernel.BrowserFWatchStopParams{ID: br.SessionID}) + err = b.fsWatch.Stop(ctx, in.WatchID, kernel.BrowserFWatchStopParams{IDOrName: br.SessionID}) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -2058,7 +2070,7 @@ func (b BrowsersCmd) FSWatchEvents(ctx context.Context, in BrowsersFSWatchEvents if err != nil { return util.CleanedUpSdkError{Err: err} } - stream := b.fsWatch.EventsStreaming(ctx, in.WatchID, kernel.BrowserFWatchEventsParams{ID: br.SessionID}) + stream := b.fsWatch.EventsStreaming(ctx, in.WatchID, kernel.BrowserFWatchEventsParams{IDOrName: br.SessionID}) if stream == nil { pterm.Error.Println("failed to open watch events stream") return nil @@ -2960,6 +2972,7 @@ func init() { browsersCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names to load (repeatable; may be passed multiple times or comma-separated)") browsersCreateCmd.Flags().String("viewport", "", "Browser viewport size (e.g., 1920x1080@25). Supported: 2560x1440@10, 1920x1080@25, 1920x1200@25, 1440x900@25, 1024x768@60, 1200x800@60, 1280x800@60") browsersCreateCmd.Flags().Bool("viewport-interactive", false, "Interactively select viewport size from list") + browsersCreateCmd.Flags().StringArray("vault", nil, "Project-owned vault ID or name to attach at creation (repeatable, max 20; incompatible with pools)") browsersCreateCmd.Flags().String("pool-id", "", "Browser pool ID to acquire from (mutually exclusive with --pool-name)") browsersCreateCmd.Flags().String("pool-name", "", "Browser pool name to acquire from (mutually exclusive with --pool-id)") browsersCreateCmd.Flags().String("telemetry", "", "Configure telemetry (opt-in): --telemetry=all (default set), --telemetry=off (disable), or --telemetry=console,network (capture exactly those categories)") @@ -3089,6 +3102,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { privateHosts, _ := cmd.Flags().GetStringSlice("private-host") startURL, _ := cmd.Flags().GetString("start-url") extensions, _ := cmd.Flags().GetStringSlice("extension") + vaults, _ := cmd.Flags().GetStringArray("vault") viewport, _ := cmd.Flags().GetString("viewport") viewportInteractive, _ := cmd.Flags().GetBool("viewport-interactive") poolID, _ := cmd.Flags().GetString("pool-id") @@ -3102,6 +3116,15 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { output, _ := cmd.Flags().GetString("output") skipConfirm, _ := cmd.Flags().GetBool("yes") + if cmd.Flags().Changed("vault") { + if len(vaults) == 0 { + return fmt.Errorf("--vault requires a vault ID or name") + } + if poolID != "" || poolName != "" { + return fmt.Errorf("--vault cannot be used with --pool-id or --pool-name; create a new browser to attach vaults") + } + } + if poolID != "" && poolName != "" { pterm.Error.Println("must specify at most one of --pool-id or --pool-name") return nil @@ -3219,6 +3242,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { PrivateHosts: privateHosts, StartURL: startURL, Extensions: extensions, + Vaults: vaults, Viewport: viewport, Telemetry: telemetry, TelemetryExport: telemetryExport, diff --git a/cmd/vaults.go b/cmd/vaults.go new file mode 100644 index 0000000..ebbe121 --- /dev/null +++ b/cmd/vaults.go @@ -0,0 +1,284 @@ +package cmd + +import ( + "context" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/kernel/cli/pkg/interactive" + "github.com/kernel/cli/pkg/util" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/pterm/pterm" +) + +var vaultNamePattern = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,255}$`) + +type VaultsCmd struct { + vaults *kernel.VaultService + prompter interactive.Prompter + openURL func(string) error +} + +func validateVaultName(value, label string) error { + if !vaultNamePattern.MatchString(value) || value == "." || value == ".." { + return fmt.Errorf("%s must contain 1-255 letters, digits, dots, underscores, or hyphens (not . or ..)", label) + } + return nil +} + +func (c VaultsCmd) Create(ctx context.Context, name, output string) error { + if err := validateVaultName(name, "--name"); err != nil { + return err + } + v, err := c.vaults.Upsert(ctx, kernel.VaultUpsertParams{Name: name}, option.WithMaxRetries(0)) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + return printVault(v, output) +} + +func (c VaultsCmd) Get(ctx context.Context, vault, output string) error { + v, err := c.vaults.Get(ctx, vault, option.WithMaxRetries(0)) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + return printVault(v, output) +} + +func (c VaultsCmd) List(ctx context.Context, limit, offset int64, project, output string) error { + if limit < 1 || limit > 100 || offset < 0 { + return fmt.Errorf("--limit must be between 1 and 100; --offset must be non-negative") + } + var response *http.Response + page, err := c.vaults.List(ctx, kernel.VaultListParams{Limit: kernel.Opt(limit), Offset: kernel.Opt(offset)}, option.WithMaxRetries(0), option.WithResponseInto(&response)) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + pagination, err := parseProjectListPagination(response) + if err != nil { + return fmt.Errorf("invalid vault pagination metadata") + } + if output == "json" { + items, err := vaultSafeJSONSlice(page.Items, vaultFields) + if err != nil { + return err + } + return printVaultJSON(struct { + Vaults []vaultJSON `json:"vaults"` + NextOffset int `json:"next_offset,omitempty"` + }{items, pagination.NextOffset}) + } + if len(page.Items) == 0 { + pterm.Info.Println("No vaults found") + } else { + rows := pterm.TableData{{"ID", "Name", "Created At"}} + for _, v := range page.Items { + rows = append(rows, []string{v.ID, v.Name, util.FormatLocal(v.CreatedAt)}) + } + PrintTableNoPad(rows, true) + } + if pagination.HasMore { + projectFlag := "" + if project != "" { + projectFlag = fmt.Sprintf(" --project %q", project) + } + pterm.Printf("Next: kernel%s vaults list --limit %d --offset %d\n", projectFlag, limit, pagination.NextOffset) + } + return nil +} + +func (c VaultsCmd) Delete(ctx context.Context, vault, key string, yes bool) error { + label := "vault " + vault + " and all its items" + if key != "" { + label = "vault item " + vault + "/" + key + } + if !yes { + ok, err := c.prompter.Confirm("delete "+label, "Delete "+label+" and invalidate its credentials?") + if err != nil { + return err + } + if !ok { + pterm.Info.Println("Deletion cancelled") + return nil + } + } + var err error + if key == "" { + err = c.vaults.Delete(ctx, vault, option.WithMaxRetries(0)) + } else { + err = c.vaults.Items.Delete(ctx, key, kernel.VaultItemDeleteParams{IDOrName: vault}, option.WithMaxRetries(0)) + } + if err != nil && !util.IsNotFound(err) { + return util.CleanedUpSdkError{Err: err} + } + pterm.Success.Println("Deleted or not found: " + label) + return nil +} + +func (c VaultsCmd) ListItems(ctx context.Context, vault, output string) error { + items, err := c.vaults.Items.List(ctx, vault, option.WithMaxRetries(0)) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if output == "json" { + data, err := vaultSafeJSONSlice(*items, vaultItemFields) + if err != nil { + return err + } + return printVaultJSON(data) + } + if len(*items) == 0 { + pterm.Info.Println("No vault items found") + return nil + } + rows := pterm.TableData{{"Key", "Type", "Provider", "Status", "Action"}} + for _, item := range *items { + rows = append(rows, []string{item.Key, item.Type, item.Spec.Provider, item.State.Status, util.OrDash(item.Action.Name)}) + } + PrintTableNoPad(rows, true) + return nil +} + +func validateVaultWait(wait int64) error { + if wait < 0 || wait > 60 { + return fmt.Errorf("--wait must be between 0 and 60 seconds") + } + return nil +} + +func (c VaultsCmd) GetItem(ctx context.Context, vault, key string, wait int64, expand []string, project, output string, open bool) error { + if err := validateVaultWait(wait); err != nil { + return err + } + for _, field := range expand { + if field != "payment_methods" { + return fmt.Errorf("--expand only supports payment_methods") + } + } + ctx, cancel := context.WithTimeout(ctx, time.Duration(wait)*time.Second+30*time.Second) + defer cancel() + item, err := c.vaults.Items.Get(ctx, key, kernel.VaultItemGetParams{IDOrName: vault, Wait: kernel.Opt(wait), Expand: expand}, option.WithMaxRetries(0)) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if err := c.showItem(item, output, open); err != nil { + return err + } + if output != "json" { + return printVaultOperationHints(item, vault, key, project) + } + return nil +} + +func (c VaultsCmd) CreateWallet(ctx context.Context, vault, key string, spec kernel.WalletVaultItemSpecUnionParam, output string, open bool) error { + item, err := c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfWallet: &kernel.VaultItemUpsertParamsBodyWallet{Spec: spec}}, option.WithMaxRetries(0)) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + 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)) + } + 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") + } + item, err := c.vaults.Items.Get(ctx, key, kernel.VaultItemGetParams{IDOrName: vault}, option.WithMaxRetries(0)) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + operations, err := vaultItemOperations(item) + if err != nil { + return err + } + available := false + for _, op := range operations { + if op.Type == operation { + available = true + if output != "json" { + pterm.Info.Println(op.Description) + } + break + } + } + 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)) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + return c.showItem(item, output, open) +} + +func (c VaultsCmd) Events(ctx context.Context, vault, key, after string, wait int64, output string) error { + if err := validateVaultWait(wait); err != nil { + return err + } + params := kernel.VaultItemEventsParams{IDOrName: vault, Wait: kernel.Opt(wait)} + if after != "" { + params.After = kernel.Opt(after) + } + ctx, cancel := context.WithTimeout(ctx, time.Duration(wait)*time.Second+30*time.Second) + defer cancel() + events, err := c.vaults.Items.Events(ctx, key, params, option.WithMaxRetries(0)) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + data, err := vaultSafeJSONSlice(*events, vaultEventFields) + if err != nil { + return err + } + if output == "json" { + return printVaultJSON(data) + } + if len(*events) == 0 { + pterm.Info.Println("No new vault item events") + return nil + } + printVaultEvents(*events, data) + pterm.Printf("For later events, pass --after %s to items events. Observing events does not retry a payment.\n", (*events)[len(*events)-1].ID) + return nil +} + +func (c VaultsCmd) showItem(item *kernel.VaultItemUnion, output string, open bool) error { + if err := printVaultItem(item, output); err != nil { + return err + } + if !open { + return nil + } + actionURL := item.Action.URL + if actionURL == "" { + if output != "json" { + pterm.Info.Println("No action URL returned; no browser opened") + } + return nil + } + u, err := url.Parse(actionURL) + if err != nil || u.Scheme != "https" || !vaultDisplayURL(actionURL) { + return fmt.Errorf("action URL is not a display-safe HTTPS URL; no browser opened") + } + if err := c.openURL(actionURL); err != nil { + return fmt.Errorf("could not open the browser; open the returned action URL manually") + } + return nil +} diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go new file mode 100644 index 0000000..c7c270d --- /dev/null +++ b/cmd/vaults_commands.go @@ -0,0 +1,254 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/kernel/cli/pkg/interactive" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/packages/param" + "github.com/pkg/browser" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(newVaultsCommand()) +} + +func getVaultsHandler(cmd *cobra.Command) VaultsCmd { + client := getKernelClient(cmd) + return VaultsCmd{vaults: &client.Vaults, prompter: interactive.NewPrompter(), openURL: browser.OpenURL} +} + +func addVaultJSONOutputFlag(cmd *cobra.Command) { + addJSONOutputFlag(cmd) + cmd.Flags().Lookup("output").Usage = "Output format: json for display-safe API fields" +} + +func vaultOutput(cmd *cobra.Command) string { + output, _ := cmd.Flags().GetString("output") + return output +} + +func vaultPreRun(cmd *cobra.Command, args []string) error { + if err := validateJSONOutput(vaultOutput(cmd)); err != nil { + return err + } + for i, arg := range args { + if i > 1 { + break + } + label := "vault ID or name" + if i == 1 { + label = "item key" + } + if err := validateVaultName(arg, label); err != nil { + return err + } + } + return nil +} + +func newVaultsCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "vaults", Aliases: []string{"vault"}, Short: "Prepare and observe project-owned payment credentials", + Long: `Prepare and observe payment credentials; vault commands do not submit merchant payments. + +Optionally select a project with --project or KERNEL_PROJECT. +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. + +Permitted checkout domains are provider-assigned and displayed when returned; +there is no domain-setting API. +Never supply card data, OAuth codes/tokens, ciphertext, or provider secrets to the CLI. +Never retry failed, timed-out, rejected, or indeterminate payments. +JSON output preserves returned public fields but omits unknown/opaque provider data.`, + Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() }, + } + + create := &cobra.Command{Use: "create --name ", Short: "Create or retrieve a vault by immutable name", Args: cobra.NoArgs, PreRunE: vaultPreRun, + RunE: func(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + return getVaultsHandler(cmd).Create(cmd.Context(), name, vaultOutput(cmd)) + }} + create.Flags().String("name", "", "Immutable vault name (required)") + _ = create.MarkFlagRequired("name") + addVaultJSONOutputFlag(create) + + list := &cobra.Command{Use: "list", Short: "List vaults in the effective project", Args: cobra.NoArgs, PreRunE: vaultPreRun, + RunE: func(cmd *cobra.Command, args []string) error { + limit, _ := cmd.Flags().GetInt64("limit") + offset, _ := cmd.Flags().GetInt64("offset") + project, _ := cmd.Flags().GetString("project") + return getVaultsHandler(cmd).List(cmd.Context(), limit, offset, resolveProjectSelection(project), vaultOutput(cmd)) + }} + list.Flags().Int64("limit", 20, "Maximum vaults to return (1-100)") + list.Flags().Int64("offset", 0, "Number of vaults to skip") + addVaultJSONOutputFlag(list) + + get := &cobra.Command{Use: "get ", Short: "Get a vault by ID or name", Args: cobra.ExactArgs(1), PreRunE: vaultPreRun, + RunE: func(cmd *cobra.Command, args []string) error { + return getVaultsHandler(cmd).Get(cmd.Context(), args[0], vaultOutput(cmd)) + }} + addVaultJSONOutputFlag(get) + cmd.AddCommand(create, list, get, newVaultDeleteCommand(false)) + + items := &cobra.Command{Use: "items", Short: "Inspect vault item state, actions, aliases, and outcomes"} + itemList := &cobra.Command{Use: "list ", Short: "List items by vault ID or name", Args: cobra.ExactArgs(1), PreRunE: vaultPreRun, + RunE: func(cmd *cobra.Command, args []string) error { + return getVaultsHandler(cmd).ListItems(cmd.Context(), args[0], vaultOutput(cmd)) + }} + addVaultJSONOutputFlag(itemList) + itemGet := &cobra.Command{Use: "get ", Short: "Get item state and any required action", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: "Get item state, available operations, provider actions, and returned checkout aliases.\n--wait is a single bounded server-side observation, not a retry or a guarantee of readiness.\nAn item still pending after the wait is returned as-is; ready does not mean paid.", + RunE: func(cmd *cobra.Command, args []string) error { + wait, _ := cmd.Flags().GetInt64("wait") + expand, _ := cmd.Flags().GetStringSlice("expand") + open, _ := cmd.Flags().GetBool("open") + project, _ := cmd.Flags().GetString("project") + return getVaultsHandler(cmd).GetItem(cmd.Context(), args[0], args[1], wait, expand, resolveProjectSelection(project), vaultOutput(cmd), open) + }} + itemGet.Flags().Int64("wait", 0, "Hold while pending for up to this many seconds (0-60); observe only") + itemGet.Flags().StringSlice("expand", nil, "Advertised live data to fetch: payment_methods") + itemGet.Flags().Bool("open", false, "Open a returned HTTPS action URL in your browser") + addVaultJSONOutputFlag(itemGet) + itemEvents := &cobra.Command{Use: "events ", Short: "Read immutable item events without retrying payments", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + RunE: func(cmd *cobra.Command, args []string) error { + after, _ := cmd.Flags().GetString("after") + wait, _ := cmd.Flags().GetInt64("wait") + return getVaultsHandler(cmd).Events(cmd.Context(), args[0], args[1], after, wait, vaultOutput(cmd)) + }} + 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", + 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) + }} + invoke.Flags().Bool("open", false, "Open a returned HTTPS action URL in your browser") + addVaultJSONOutputFlag(invoke) + items.AddCommand(itemList, itemGet, itemEvents, invoke, newVaultDeleteCommand(true)) + + wallets := &cobra.Command{Use: "wallets", Short: "Connect provider wallets and inspect funding methods"} + walletCreate := &cobra.Command{Use: "create --provider --spec ''", Short: "Create a wallet and display its connection or enrollment action", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: "Create a wallet at an immutable key and follow the returned provider action.\n" + vaultSpecHelp + vaultWalletSpecHelp, + Example: ` kernel vaults wallets create checkout wallet-1 \ + --provider link --spec '{ + "authorization": { + "method": "oauth", + "client": {"type": "kernel_managed"} + } + }' --open + + kernel vaults wallets create checkout wallet-1 \ + --provider agentcard --spec '{}'`, + RunE: func(cmd *cobra.Command, args []string) error { + spec, err := vaultSpecFromFlags(cmd) + if err != nil { + return err + } + open, _ := cmd.Flags().GetBool("open") + return getVaultsHandler(cmd).CreateWallet(cmd.Context(), args[0], args[1], param.Override[kernel.WalletVaultItemSpecUnionParam](spec), vaultOutput(cmd), open) + }} + addVaultSpecFlags(walletCreate) + walletCreate.Flags().Bool("open", false, "Open the returned HTTPS connection/enrollment URL") + addVaultJSONOutputFlag(walletCreate) + methods := &cobra.Command{Use: "payment-methods ", Short: "Fetch advertised live wallet payment methods", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: "Fetch payment_methods through the item's GET expansion. The wallet must advertise this expansion.\nDisplays selectable IDs and advisory capabilities; never automatically chooses a funding method.\nJSON returns the item with expanded.payment_methods, like items get --expand payment_methods.", + RunE: func(cmd *cobra.Command, args []string) error { + project, _ := cmd.Flags().GetString("project") + return getVaultsHandler(cmd).GetItem(cmd.Context(), args[0], args[1], 0, []string{"payment_methods"}, resolveProjectSelection(project), vaultOutput(cmd), false) + }} + 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) + return cmd +} + +func newVaultDeleteCommand(item bool) *cobra.Command { + use, short, nargs := "delete ", "Delete a vault and invalidate all its items", 1 + if item { + use, short, nargs = "delete ", "Delete an item and invalidate its credential", 2 + } + cmd := &cobra.Command{Use: use, Short: short, Args: cobra.ExactArgs(nargs), PreRunE: vaultPreRun, + RunE: func(cmd *cobra.Command, args []string) error { + key := "" + if item { + key = args[1] + } + yes, _ := cmd.Flags().GetBool("yes") + return getVaultsHandler(cmd).Delete(cmd.Context(), args[0], key, yes) + }} + cmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + return cmd +} + +func newVaultCardCommand(update bool) *cobra.Command { + use, short := "create", "Create a card request without authorizing it" + if update { + use, short = "update", "Replace 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. +Update replaces the entire spec; omitted optional details are removed. +Never reconfigure to retry a failed, timed-out, rejected, or indeterminate payment. +` + vaultSpecHelp + vaultCardSpecHelp, + Example: " kernel vaults cards " + use + ` checkout order-1 \ + --provider agentcard --spec '{ + "wallet": "wallet-1", + "merchant": "Example Shop", + "amount": 1234, + "currency": "usd" + }'`, + 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)) + }} + addVaultSpecFlags(cmd) + addVaultJSONOutputFlag(cmd) + return cmd +} + +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") + _ = cmd.MarkFlagRequired("provider") + _ = cmd.MarkFlagRequired("spec") +} + +func vaultSpecFromFlags(cmd *cobra.Command) (map[string]json.RawMessage, error) { + provider, _ := cmd.Flags().GetString("provider") + if provider != "link" && provider != "agentcard" { + return nil, fmt.Errorf("--provider must be link or agentcard") + } + 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 value, ok := spec["provider"]; ok { + var embedded string + if err := json.Unmarshal(value, &embedded); err != nil || embedded != provider { + return nil, fmt.Errorf("spec.provider must match --provider") + } + } + spec["provider"], _ = json.Marshal(provider) + return spec, nil +} diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go new file mode 100644 index 0000000..989c97e --- /dev/null +++ b/cmd/vaults_help.go @@ -0,0 +1,74 @@ +package cmd + +// Keep these help types in sync with https://api.onkernel.com/spec.yaml. +const vaultSpecHelp = ` +--spec takes the specification object, not the {type, spec} request envelope. +--provider supplies spec.provider; omit it from JSON or supply the same value. +The API validates provider-specific fields. Values are forwarded without defaults +or normalization. Never include card data, OAuth tokens, or provider secrets. + +// Keep these types in sync with https://api.onkernel.com/spec.yaml. +// TypeScript notation: ? means optional. Other fields are required. +// The provider field below is supplied by --provider. +` + +const vaultWalletSpecHelp = ` +type LinkWalletSpec = { + provider: "link"; + authorization: { + method: "oauth"; + client: { type: "kernel_managed" }; + }; +}; + +type AgentCardWalletSpec = { + provider: "agentcard"; + user_id?: string; // usr_...; already enrolled in this organization +}; +` + +const vaultCardSpecHelp = ` +type LinkCardSpec = { + provider: "link"; + wallet: string; // wallet item key + payment_method_id: string; // from wallets payment-methods + amount: number; // integer minor units; 1..500000 + currency: string; // three letters + merchant_name: string; // 1..255 characters + merchant_url: string; // URI + context: string; // at least 100 characters + line_items?: LinkLineItem[]; + totals?: LinkTotal[]; + metadata?: Record; + expires_at?: number; // int64 +}; + +type AgentCardCardSpec = { + provider: "agentcard"; + wallet: string; // wallet item key + merchant: string; // approval-screen name; 1..120 characters + amount: number; // integer minor units; 1..9007199254740991 + currency: string; // three letters + card_id?: string; // vc_...; otherwise chosen at approval +}; + +type LinkLineItem = { + name: string; + quantity?: number; // integer >= 1 + unit_amount?: number; // integer minor units + description?: string; + sku?: string; + url?: string; + image_url?: string; + product_url?: string; + totals?: LinkTotal[]; +}; + +type LinkTotal = { + type: string; + 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 new file mode 100644 index 0000000..fa18509 --- /dev/null +++ b/cmd/vaults_invoke_test.go @@ -0,0 +1,184 @@ +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) + }) + } +} + +func TestVaultInvokeRejectsUnadvertisedOperation(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) + }) + } +} + +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") }) + for _, args := range [][]string{ + {"checkout", "order-1"}, + {"checkout", "order-1", ""}, + {"checkout", "order-1", "authorize", "extra"}, + {"checkout", "order-1", "authorize", "--spec", "{}"}, + } { + _, _, 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.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) { + 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) + }) + } + } +} diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go new file mode 100644 index 0000000..fba1d13 --- /dev/null +++ b/cmd/vaults_output.go @@ -0,0 +1,335 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/kernel/cli/pkg/util" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/pterm/pterm" +) + +type vaultJSON map[string]json.RawMessage +type vaultOutputFields map[string]vaultOutputFields + +func vaultFieldsOf(names string) vaultOutputFields { + fields := make(vaultOutputFields) + for _, name := range strings.Fields(names) { + fields[name] = nil + } + return fields +} + +var vaultFields = vaultFieldsOf("id name created_at updated_at") +var vaultOperationFields = vaultFieldsOf("type description") +var vaultTotalFields = vaultFieldsOf("type display_text amount") +var vaultMethodFields = vaultOutputFields{ + "id": nil, "provider": nil, "type": nil, "is_default": nil, + "display": vaultFieldsOf("label brand last4"), + "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, + "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, + "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, + "context": nil, "expires_at": nil, + "authorization": {"method": nil, "client": vaultFieldsOf("type")}, + "totals": vaultTotalFields, + "line_items": { + "name": nil, "quantity": nil, "unit_amount": nil, "description": nil, + "sku": nil, "url": nil, "image_url": nil, "product_url": nil, "totals": vaultTotalFields, + }, + }, + "state": { + "provider": nil, "status": nil, "status_reason": nil, "user_id": nil, "domains": nil, + "masks": vaultFieldsOf("brand last4"), + "aliases": vaultFieldsOf("number cvc exp_month exp_year"), + "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"), + }, +} +var vaultEventFields = vaultOutputFields{ + "id": nil, "name": nil, "created_at": nil, "browser_id": nil, + "data": vaultFieldsOf("reason 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"), +} + +// Vault output is a display-safe projection, not raw provider JSON. Keep presence +// information while dropping unknown fields and opaque event data at every level. +func filterVaultJSON(raw json.RawMessage, fields vaultOutputFields) (json.RawMessage, error) { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 { + return nil, fmt.Errorf("empty vault response") + } + if bytes.Equal(raw, []byte("null")) { + return raw, nil + } + if raw[0] == '[' { + var values []json.RawMessage + if err := json.Unmarshal(raw, &values); err != nil { + return nil, err + } + for i, value := range values { + filtered, err := filterVaultJSON(value, fields) + if err != nil { + return nil, err + } + values[i] = filtered + } + return json.Marshal(values) + } + if fields == nil { + if raw[0] == '{' { + return json.RawMessage("null"), nil + } + return raw, nil + } + var object vaultJSON + if err := json.Unmarshal(raw, &object); err != nil { + return nil, fmt.Errorf("invalid vault response shape") + } + 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" { + var address string + if json.Unmarshal(value, &address) != nil || !vaultDisplayURL(address) { + continue + } + } + filtered, err := filterVaultJSON(value, children) + if err != nil { + return nil, err + } + result[key] = filtered + } + } + return json.Marshal(result) +} + +func vaultSafeJSONSlice[T util.RawJSONProvider](items []T, fields vaultOutputFields) ([]vaultJSON, error) { + result := make([]vaultJSON, 0, len(items)) + for _, item := range items { + raw, err := filterVaultJSON(json.RawMessage(item.RawJSON()), fields) + if err != nil { + return nil, err + } + var value vaultJSON + if err := json.Unmarshal(raw, &value); err != nil { + return nil, err + } + result = append(result, value) + } + return result, nil +} + +func printVaultJSON(value any) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil +} + +func printVault(v *kernel.Vault, output string) error { + if output == "json" { + raw, err := filterVaultJSON(json.RawMessage(v.RawJSON()), vaultFields) + if err != nil { + return err + } + return printVaultJSON(raw) + } + PrintTableNoPad(pterm.TableData{ + {"Property", "Value"}, {"ID", v.ID}, {"Name (immutable)", v.Name}, + {"Created At", util.FormatLocal(v.CreatedAt)}, {"Updated At", util.FormatLocal(v.UpdatedAt)}, + }, true) + return nil +} + +func vaultDisplayURL(address string) bool { + u, err := url.Parse(address) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Hostname() == "" || u.User != nil { + return false + } + query, err := url.ParseQuery(u.RawQuery) + if err != nil { + return false + } + fragment, err := url.ParseQuery(u.Fragment) + if err != nil { + return false + } + for _, values := range []url.Values{query, fragment} { + for key := range values { + switch strings.ToLower(key) { + case "code", "access_token", "refresh_token", "id_token", "client_secret", "password": + return false + } + } + } + return true +} + +type vaultItemOperation struct { + Type string `json:"type"` + Description string `json:"description"` +} + +func vaultItemOperations(item *kernel.VaultItemUnion) ([]vaultItemOperation, error) { + var fields struct { + Operations []vaultItemOperation `json:"available_operations"` + } + if err := json.Unmarshal([]byte(item.RawJSON()), &fields); err != nil { + return nil, fmt.Errorf("invalid vault item operations: %w", err) + } + return fields.Operations, nil +} + +func vaultShellArgument(value string) string { + if vaultNamePattern.MatchString(value) { + return value + } + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} + +func printVaultOperationHints(item *kernel.VaultItemUnion, vault, key, project string) error { + operations, err := vaultItemOperations(item) + if err != nil { + return err + } + prefix := "kernel vaults items invoke" + if project != "" { + prefix += " --project=" + vaultShellArgument(project) + } + for _, op := range operations { + pterm.Printf("Invoke: %s -- %s %s %s\n", prefix, vaultShellArgument(vault), vaultShellArgument(key), vaultShellArgument(op.Type)) + } + return nil +} + +func printVaultItem(item *kernel.VaultItemUnion, output string) error { + raw, err := filterVaultJSON(json.RawMessage(item.RawJSON()), vaultItemFields) + if err != nil { + return err + } + if output == "json" { + return printVaultJSON(raw) + } + var safe kernel.VaultItemUnion + if err := json.Unmarshal(raw, &safe); err != nil { + return fmt.Errorf("invalid vault item response") + } + item = &safe + rows := pterm.TableData{ + {"Property", "Value"}, {"Key (immutable)", item.Key}, {"ID", item.ID}, + {"Type", item.Type}, {"Provider", item.Spec.Provider}, {"Status", item.State.Status}, + } + if item.State.StatusReason != "" { + rows = append(rows, []string{"Status reason", item.State.StatusReason}) + } + if item.Type == "card" { + merchant := item.Spec.MerchantName + if item.Spec.Provider == "agentcard" { + 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}) + } + } + if item.State.JSON.Domains.Valid() { + rows = append(rows, []string{"Permitted domains (provider-assigned)", strings.Join(item.State.Domains, ", ")}) + } + if item.Action.Name != "" { + rows = append(rows, []string{"Required action", item.Action.Name}) + } + if !item.ExpiresAt.IsZero() { + rows = append(rows, []string{"Expires At", util.FormatLocal(item.ExpiresAt)}) + } + if 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}) + } + if item.State.JSON.Authorization.Valid() { + a := item.State.Authorization + rows = append(rows, []string{"Checkout authorization", a.ID}, []string{"Authorization status", string(a.Status)}) + if a.Reason != "" { + rows = append(rows, []string{"Authorization reason", a.Reason}) + } + if a.JSON.ChargedKind.Valid() { + rows = append(rows, []string{"Charged kind", string(a.ChargedKind)}, []string{"Charged (minor units)", fmt.Sprintf("%d %s", a.ChargedAmountCents, a.ChargedCurrency)}) + } + if a.JSON.ReplayDelivered.Valid() { + rows = append(rows, []string{"Processor response delivered", fmt.Sprint(a.ReplayDelivered)}) + } + } + PrintTableNoPad(rows, true) + if item.Action.Name != "" && item.Action.URL != "" { + pterm.Printf("Action URL:\n%s\n", item.Action.URL) + } + if item.State.JSON.Authorization.Valid() && item.State.Authorization.ApprovalURL != "" { + pterm.Printf("Approval URL:\n%s\n", item.State.Authorization.ApprovalURL) + } + operations, err := vaultItemOperations(item) + if err != nil { + return err + } + for _, op := range operations { + pterm.Printf("Available operation: %s — %s\n", op.Type, op.Description) + } + if item.Type == "card" { + card := item.AsCard() + for _, expansion := range card.AvailableExpansions { + pterm.Printf("Available expansion: %s — %s\n", expansion.Type, expansion.Description) + } + if item.State.JSON.Aliases.Valid() { + pterm.Info.Println("Aliases are non-secret checkout values. Use only in a browser created with this vault attached; ready does not mean paid.") + } + pterm.Info.Println("Inspect items events for payment outcomes. Do not retry failed, timed-out, rejected, or indeterminate payments.") + } else { + wallet := item.AsWallet() + for _, expansion := range wallet.AvailableExpansions { + pterm.Printf("Available expansion: %s — %s\n", expansion.Type, expansion.Description) + } + } + if item.Expanded.JSON.PaymentMethods.Valid() { + printVaultPaymentMethods(item.Expanded.PaymentMethods) + } + if item.Action.Name != "" { + pterm.Info.Println("Complete the returned action with the provider; never pass card data or OAuth codes to the CLI. Observe with items get --wait 60.") + } + return nil +} + +func printVaultPaymentMethods(methods []kernel.VaultPaymentMethod) { + if len(methods) == 0 { + pterm.Info.Println("No payment methods returned") + return + } + rows := pterm.TableData{{"Payment method ID", "Provider", "Type", "Label", "Brand", "Last4", "Default", "Single-use eligible", "Reasons"}} + for _, m := range methods { + capability := m.Capabilities.SingleUseCard + eligible := "unknown" + if capability.JSON.Eligible.Valid() { + eligible = fmt.Sprint(capability.Eligible) + } + rows = append(rows, []string{m.ID, m.Provider, m.Type, m.Display.Label, m.Display.Brand, m.Display.Last4, fmt.Sprint(m.IsDefault), eligible, strings.Join(capability.Reasons, ", ")}) + } + 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.") +} + +func printVaultEvents(events []kernel.VaultItemEvent, data []vaultJSON) { + rows := pterm.TableData{{"Event ID", "Time", "Name", "Browser ID", "Outcome data"}} + for i, event := range events { + rows = append(rows, []string{event.ID, util.FormatLocal(event.CreatedAt), event.Name, util.OrDash(event.BrowserID), string(data[i]["data"])}) + } + PrintTableNoPad(rows, true) +} diff --git a/cmd/vaults_output_test.go b/cmd/vaults_output_test.go new file mode 100644 index 0000000..fe757e1 --- /dev/null +++ b/cmd/vaults_output_test.go @@ -0,0 +1,198 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + kernel "github.com/kernel/kernel-go-sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const readyCardFixture = `{ + "id":"card-id","key":"order-1","type":"card", + "spec":{"provider":"link","wallet":"wallet-1","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example","provider_secret":"SECRET_SPEC"}, + "state":{"provider":"link","status":"ready","domains":["shop.example"],"aliases":{"number":"9999999999999999","cvc":"999","exp_month":"01","exp_year":"2099","secret":"SECRET_ALIAS"},"card_number":"SECRET_CARD","secret_enc":"SECRET_CIPHERTEXT"}, + "available_operations":[],"available_expansions":[],"oauth_tokens":"SECRET_OAUTH" +}` + +func TestVaultOutputAliasesPresenceAndRedaction(t *testing.T) { + var item kernel.VaultItemUnion + require.NoError(t, json.Unmarshal([]byte(readyCardFixture), &item)) + buf := capturePtermOutput(t) + require.NoError(t, printVaultItem(&item, "")) + human := buf.String() + 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, "Do not retry") + assert.NotContains(t, human, "SECRET") + out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) + assert.NotContains(t, out, "SECRET") + assert.Contains(t, out, "9999999999999999") + assert.NotContains(t, out, "authorization") + assert.NotContains(t, out, "expires_at") + + 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(), "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) + 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`) +} + +func TestVaultOutputAgentCardAuthorizationIsNotPaymentSuccess(t *testing.T) { + var item kernel.VaultItemUnion + require.NoError(t, json.Unmarshal([]byte(`{ + "id":"card-id","key":"order-1","type":"card", + "spec":{"provider":"agentcard","wallet":"wallet-1","amount":1234,"currency":"usd","merchant":"Example Shop"}, + "state":{"provider":"agentcard","status":"ready","authorization":{"id":"cauth_test","status":"declined","psp":"stripe","merchant":"Example Shop","amount_cents":1234,"currency":"usd","reason":"expired","charged_kind":"none","replay_delivered":false,"raw_response":"SECRET_RESPONSE"}}, + "available_operations":[],"available_expansions":[] + }`), &item)) + buf := capturePtermOutput(t) + require.NoError(t, printVaultItem(&item, "")) + for _, text := range []string{"Authorization status", "declined", "expired", "Charged kind", "none", "Processor response delivered", "false"} { + assert.Contains(t, buf.String(), text) + } + assert.NotContains(t, buf.String(), "SECRET_RESPONSE") + out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) + assert.NotContains(t, out, `"test"`) + assert.NotContains(t, out, "SECRET_RESPONSE") +} + +func TestVaultOutputOmitsCardTestMode(t *testing.T) { + for _, provider := range []string{"link", "agentcard"} { + t.Run(provider, func(t *testing.T) { + body := strings.ReplaceAll(requestedCardFixture, `"provider":"link"`, `"provider":"`+provider+`"`) + body = strings.ReplaceAll(body, `"amount":1234`, `"amount":1234,"test":true`) + var item kernel.VaultItemUnion + require.NoError(t, json.Unmarshal([]byte(body), &item)) + buf := capturePtermOutput(t) + require.NoError(t, printVaultItem(&item, "")) + assert.NotContains(t, buf.String(), "Test") + assert.NotContains(t, buf.String(), "Mode") + out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) + assert.NotContains(t, out, `"test"`) + }) + } +} + +func TestVaultOutputPaymentMethodsAdvisoryUnknownVsFalse(t *testing.T) { + body := strings.TrimSuffix(connectedWalletFixture, "}") + `,"expanded":{"payment_methods":[ + {"id":"pm-unknown","provider":"link","type":"card","is_default":true,"display":{"label":"Personal","brand":"visa","last4":"1234"},"capabilities":{},"provider_secret":"SECRET_METHOD"}, + {"id":"pm-ineligible","provider":"link","type":"card","is_default":false,"display":{},"capabilities":{"single_use_card":{"eligible":false,"reasons":["not_supported"]}}} + ]}}` + var item kernel.VaultItemUnion + 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"} { + assert.Contains(t, buf.String(), text) + } + out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) + assert.NotContains(t, out, "SECRET_METHOD") + assert.Contains(t, out, `"capabilities": {}`) +} + +func TestVaultActionOutputNoInventedURLs(t *testing.T) { + for _, action := range []struct{ name, url string }{ + {"link_oauth", "https://provider.example/auth?state=state&code_challenge=challenge"}, + {"spend_approval", "https://provider.example/approve"}, + {"card_enrollment", "https://provider.example/enroll"}, + {"collect", ""}, {"mfa", ""}, {"push_approval", ""}, {"embedded_ceremony", ""}, + } { + t.Run(action.name, func(t *testing.T) { + var item kernel.VaultItemUnion + body := strings.TrimSuffix(connectedWalletFixture, "}") + fmt.Sprintf(`,"action":{"name":%q`, action.name) + if action.url != "" { + body += fmt.Sprintf(`,"url":%q`, action.url) + } + body += "}}" + require.NoError(t, json.Unmarshal([]byte(body), &item)) + buf := capturePtermOutput(t) + require.NoError(t, printVaultItem(&item, "")) + assert.Contains(t, buf.String(), action.name) + if action.url != "" { + assert.Contains(t, buf.String(), action.url) + } else { + assert.NotContains(t, buf.String(), "Action URL") + } + }) + } +} + +func TestVaultLongURLsPrintedOutsideTable(t *testing.T) { + actionURL := "https://provider.example/auth?state=" + strings.Repeat("a", 512) + "&code_challenge=complete-challenge" + approvalURL := "https://provider.example/approve?request=" + strings.Repeat("b", 512) + for _, tt := range []struct { + name, body, label, url string + }{ + {"action", strings.TrimSuffix(connectedWalletFixture, "}") + fmt.Sprintf(`,"action":{"name":"link_oauth","url":%q}}`, actionURL), "Action URL", actionURL}, + {"approval", fmt.Sprintf(`{"key":"order-1","type":"card","spec":{"provider":"agentcard"},"state":{"provider":"agentcard","status":"ready","authorization":{"status":"pending","approval_url":%q}}}`, approvalURL), "Approval URL", approvalURL}, + } { + t.Run(tt.name, func(t *testing.T) { + var item kernel.VaultItemUnion + require.NoError(t, json.Unmarshal([]byte(tt.body), &item)) + buf := capturePtermOutput(t) + require.NoError(t, printVaultItem(&item, "")) + assert.Contains(t, buf.String(), tt.label+":\n"+tt.url+"\n") + assert.Equal(t, 1, strings.Count(buf.String(), tt.url)) + out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) + var decoded kernel.VaultItemUnion + require.NoError(t, json.Unmarshal([]byte(out), &decoded)) + if tt.name == "action" { + assert.Equal(t, tt.url, decoded.Action.URL) + } else { + assert.Equal(t, tt.url, decoded.State.Authorization.ApprovalURL) + } + }) + } +} + +func TestVaultURLsWithSecretsAreWithheld(t *testing.T) { + for _, address := range []string{ + "https://user:SECRET@provider.example/", "https://provider.example/?code=SECRET", + "https://provider.example/#access_token=SECRET", "javascript:SECRET", + } { + var item kernel.VaultItemUnion + require.NoError(t, json.Unmarshal([]byte(strings.TrimSuffix(connectedWalletFixture, "}")+fmt.Sprintf(`,"action":{"name":"link_oauth","url":%q}}`, address)), &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") + } +} + +func TestVaultGetCancellation(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var calls atomic.Int32 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + cancel() + <-r.Context().Done() + }) + c := VaultsCmd{vaults: &client.Vaults} + err := c.GetItem(ctx, "checkout", "order-1", 60, nil, "", "json", false) + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) + assert.Equal(t, int32(1), calls.Load()) +} diff --git a/cmd/vaults_spec_test.go b/cmd/vaults_spec_test.go new file mode 100644 index 0000000..b20f207 --- /dev/null +++ b/cmd/vaults_spec_test.go @@ -0,0 +1,101 @@ +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVaultRawSpecForwarding(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, path := range []string{"wallets create", "cards create", "cards update"} { + for _, provider := range []string{"link", "agentcard"} { + for _, raw := range []string{ + `{}`, + `{"custom_option":false,"amount":0,"currency":"USD","metadata":null}`, + `{"expires_at":9223372036854775807,"line_items":[{"name":"Item","quantity":2,"unit_amount":100,"totals":[{"type":"tax","display_text":"Tax","amount":10}]}],"totals":[],"metadata":{"reference":"order-1"}}`, + } { + t.Run(path+"/"+provider+"/"+raw, func(t *testing.T) { + var expected map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(raw), &expected)) + expected["provider"], _ = json.Marshal(provider) + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + 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, 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) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, requestedCardFixture) + }) + args := append([]string{"vaults"}, strings.Fields(path)...) + args = append(args, "checkout", "item-1", "--provider", provider, "--spec", raw, "-o", "json") + _, _, err := executeVaultCommand(t, client, args...) + require.NoError(t, err) + assert.Equal(t, 1, calls) + }) + } + } + } +} + +func TestVaultRawSpecValidationIsLeftToAPI(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var body struct { + Spec map[string]json.RawMessage `json:"spec"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.NotContains(t, body.Spec, "wallet") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = 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") +} + +func TestVaultSpecHelpAndFlags(t *testing.T) { + for _, path := range []string{"wallets create", "cards create", "cards update"} { + t.Run(path, func(t *testing.T) { + cmd, _, err := newVaultsCommand().Find(strings.Fields(path)) + require.NoError(t, err) + require.NotNil(t, cmd.Flags().Lookup("provider")) + require.NotNil(t, cmd.Flags().Lookup("spec")) + for _, removed := range []string{"wallet", "amount", "currency", "merchant", "merchant-url", "payment-method-id", "context", "test", "live", "card-id", "user-id"} { + assert.Nil(t, cmd.Flags().Lookup(removed), removed) + } + assert.Contains(t, cmd.Long, "Keep these types in sync with https://api.onkernel.com/spec.yaml") + assert.Contains(t, cmd.Long, "TypeScript notation") + assert.Contains(t, cmd.Long, "provider: \"link\"") + assert.Contains(t, cmd.Long, "provider: \"agentcard\"") + assert.Contains(t, cmd.Example, "--spec '") + 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"} { + assert.Contains(t, cmd.Long, field) + } + } else { + assert.Contains(t, cmd.Long, "authorization:") + assert.Contains(t, cmd.Long, "user_id?:") + } + }) + } +} diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go new file mode 100644 index 0000000..67afa4d --- /dev/null +++ b/cmd/vaults_test.go @@ -0,0 +1,529 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/kernel/cli/pkg/interactive" + "github.com/kernel/cli/pkg/util" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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."}]}` + +func vaultTestClient(t *testing.T, handler http.HandlerFunc) kernel.Client { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + return kernel.NewClient(option.WithBaseURL(server.URL), option.WithAPIKey("test")) +} + +func executeVaultCommand(t *testing.T, client kernel.Client, args ...string) (string, string, error) { + t.Helper() + root := &cobra.Command{Use: "kernel", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().String("project", "", "Project") + root.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + project, _ := cmd.Flags().GetString("project") + scoped := client + scoped.Vaults = kernel.NewVaultService(append(client.Options, option.WithProject(resolveProjectSelection(project)))...) + cmd.SetContext(context.WithValue(cmd.Context(), util.KernelClientKey, scoped)) + return nil + } + root.AddCommand(newVaultsCommand()) + root.SetArgs(args) + buf := capturePtermOutput(t) + var err error + stdout := captureStdout(t, func() { err = root.Execute() }) + return stdout, buf.String(), err +} + +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"} { + t.Run(path, func(t *testing.T) { + cmd, remaining, err := newVaultsCommand().Find(strings.Fields(path)) + require.NoError(t, err) + require.Empty(t, remaining) + assert.NotNil(t, cmd.RunE) + assert.NotNil(t, cmd.PreRunE) + assert.NotNil(t, cmd.Args) + if cmd.Name() == "delete" { + assert.NotNil(t, cmd.Flags().Lookup("yes")) + } else { + require.NotNil(t, cmd.Flags().Lookup("output")) + assert.Contains(t, cmd.Flags().Lookup("output").Usage, "display-safe") + } + }) + } + 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"} { + cmd, remaining, _ := newVaultsCommand().Find(strings.Fields(unsupported)) + assert.True(t, len(remaining) > 0 || cmd.RunE == nil, unsupported) + } +} + +func TestVaultRequiredAndInvalidFlags(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "project-test") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid input reached API") }) + tests := []struct { + args string + want string + }{ + {"vaults create", "required flag"}, + {"vaults create --name=", "--name"}, + {"vaults create --name=bad/name", "--name"}, + {"vaults create --name=..", "--name"}, + {"vaults get", "accepts 1 arg"}, + {"vaults get bad%2Fname", "vault ID or name"}, + {"vaults items get checkout bad/key", "item key"}, + {"vaults list --limit 0", "--limit"}, + {"vaults list --limit 101", "--limit"}, + {"vaults list --offset -1", "--offset"}, + {"vaults list -o yaml", "output"}, + {"vaults items get checkout wallet-1 --wait -1", "--wait"}, + {"vaults items get checkout wallet-1 --wait 61", "--wait"}, + {"vaults items events checkout wallet-1 --wait 61", "--wait"}, + {"vaults items get checkout wallet-1 --expand secret", "--expand"}, + {"vaults wallets create checkout wallet-1", "required flag"}, + {"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"}, + {"vaults delete checkout", "--yes"}, + {"vaults items delete checkout order-1", "--yes"}, + {"vaults cards create checkout order-1 --domain shop.example", "unknown flag"}, + {"vaults create --name checkout --project-id project-test", "unknown flag"}, + } + for _, tt := range tests { + t.Run(tt.args, func(t *testing.T) { + _, _, err := executeVaultCommand(t, client, strings.Fields(tt.args)...) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.want) + }) + } +} + +func TestVaultCommandsWithoutProject(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + tests := []struct { + args []string + response string + calls int + }{ + {[]string{"create", "--name", "checkout"}, vaultFixture, 1}, + {[]string{"list"}, "[" + vaultFixture + "]", 1}, + {[]string{"get", "checkout"}, vaultFixture, 1}, + {[]string{"delete", "checkout", "--yes"}, "", 1}, + {[]string{"items", "list", "checkout"}, "[" + requestedCardFixture + "]", 1}, + {[]string{"items", "get", "checkout", "order-1"}, requestedCardFixture, 1}, + {[]string{"items", "delete", "checkout", "order-1", "--yes"}, "", 1}, + {[]string{"items", "events", "checkout", "order-1"}, "[]", 1}, + {[]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) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Empty(t, r.Header.Get("X-Kernel-Project")) + if r.Method == http.MethodDelete { + w.WriteHeader(http.StatusNoContent) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Has-More", "false") + w.Header().Set("X-Next-Offset", "0") + _, _ = io.WriteString(w, tt.response) + }) + _, _, err := executeVaultCommand(t, client, append([]string{"vaults"}, tt.args...)...) + require.NoError(t, err) + assert.Equal(t, tt.calls, calls) + }) + } +} + +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))} +} + +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 _, 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)...) + args = append(args, "checkout", "item-1", "--provider", "link", "--spec", spec) + _, _, err := executeVaultCommand(t, client, args...) + require.Error(t, err) + }) + } + } +} + +func TestVaultCreateScopeAndImmutableName(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "env-project") + project := "env-project" + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/vaults", r.URL.Path) + assert.Equal(t, project, r.Header.Get("X-Kernel-Project")) + body, _ := io.ReadAll(r.Body) + assert.JSONEq(t, `{"name":"checkout"}`, string(body)) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, vaultFixture) + }) + out, human, err := executeVaultCommand(t, client, "vaults", "create", "--name", "checkout", "-o", "json") + require.NoError(t, err) + assert.JSONEq(t, vaultFixture, out) + assert.Empty(t, human) + project = "flag-project" + _, human, err = executeVaultCommand(t, client, "--project", project, "vaults", "create", "--name", "checkout") + require.NoError(t, err) + assert.Contains(t, human, "Name (immutable)") + assert.Contains(t, human, "checkout") +} + +func TestVaultListPaginationAndEmptyJSON(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "project-test") + body, hasMore, next := "["+vaultFixture+"]", "true", "21" + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/vaults", r.URL.Path) + assert.Equal(t, "1", r.URL.Query().Get("limit")) + assert.Equal(t, "20", r.URL.Query().Get("offset")) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Has-More", hasMore) + w.Header().Set("X-Next-Offset", next) + _, _ = io.WriteString(w, body) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "list", "--limit", "1", "--offset", "20", "-o", "json") + require.NoError(t, err) + assert.JSONEq(t, `{"vaults":[`+vaultFixture+`],"next_offset":21}`, out) + _, human, err := executeVaultCommand(t, client, "vaults", "list", "--limit", "1", "--offset", "20") + require.NoError(t, err) + assert.Contains(t, human, `kernel --project "project-test" vaults list --limit 1 --offset 21`) + t.Setenv("KERNEL_PROJECT", "") + _, human, err = executeVaultCommand(t, client, "vaults", "list", "--limit", "1", "--offset", "20") + require.NoError(t, err) + assert.Contains(t, human, "kernel vaults list --limit 1 --offset 21") + assert.NotContains(t, human, "--project") + body, hasMore, next = "[]", "false", "0" + out, _, err = executeVaultCommand(t, client, "vaults", "list", "--limit", "1", "--offset", "20", "-o", "json") + require.NoError(t, err) + assert.JSONEq(t, `{"vaults":[]}`, out) +} + +func TestVaultWalletRequestMapping(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "project-test") + for _, tt := range []struct { + provider, userID, spec string + }{ + {"link", "", `{"provider":"link","authorization":{"method":"oauth","client":{"type":"kernel_managed"}}}`}, + {"agentcard", "", `{"provider":"agentcard"}`}, + {"agentcard", "usr_enrolled", `{"provider":"agentcard","user_id":"usr_enrolled"}`}, + } { + t.Run(tt.provider+tt.userID, func(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + assert.Equal(t, "/vaults/checkout/items/wallet-1", r.URL.Path) + body, _ := io.ReadAll(r.Body) + assert.JSONEq(t, `{"type":"wallet","spec":`+tt.spec+`}`, string(body)) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, connectedWalletFixture) + }) + args := []string{"vaults", "wallets", "create", "checkout", "wallet-1", "--provider", tt.provider, "--spec", tt.spec, "-o", "json"} + out, human, err := executeVaultCommand(t, client, args...) + require.NoError(t, err) + assert.JSONEq(t, connectedWalletFixture, out) + assert.Empty(t, human) + }) + } +} + +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"}`} + } + 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") + }) + } + } +} + +func TestVaultInvokeRequiresAdvertisedOperation(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)) + _, _ = 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) + } + }) + } + } +} + +func TestVaultNoSDKRetriesAndAPIErrorMessages(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) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = io.WriteString(w, requestedCardFixture) + return + } + w.WriteHeader(status) + _, _ = io.WriteString(w, `{"message":"Authorization service unavailable","code":"authorization_failed"}`) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "authorize", "-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()) + }) + } +} + +func TestVaultInvalidProjectErrors(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + commands := [][]string{ + {"list"}, {"get", "checkout"}, {"create", "--name", "checkout"}, + {"items", "list", "checkout"}, {"items", "get", "checkout", "order-1"}, + {"items", "events", "checkout", "order-1"}, + {"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"}, + } + for _, project := range []string{"doesntexist", "abcdefghijklmnopqrstuvwx"} { + for _, args := range commands { + t.Run(project+"/"+strings.Join(args[:min(2, len(args))], " "), func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, project, r.Header.Get("X-Kernel-Project")) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"code":"project_not_found","message":"Project not found or inactive"}`) + }) + out, human, err := executeVaultCommand(t, client, append([]string{"--project", project, "vaults"}, args...)...) + require.Error(t, err) + assert.Equal(t, "project_not_found: Project not found or inactive", util.CleanedUpSdkError{Err: err}.Error()) + assert.Equal(t, 1, calls) + assert.Empty(t, out) + assert.NotContains(t, human, "Deleted") + }) + } + } +} + +func TestVaultPlainTextAPIError(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "Credential is scoped to a different project", http.StatusForbidden) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "list", "--project", "other-project", "-o", "json") + require.Error(t, err) + assert.Empty(t, out) + assert.Contains(t, util.CleanedUpSdkError{Err: err}.Error(), "Credential is scoped to a different project") + assert.NotContains(t, err.Error(), "withheld") +} + +func TestVaultGetWaitExpansionAndEvents(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "project-test") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/vaults/checkout/items/wallet-1": + assert.Equal(t, "60", r.URL.Query().Get("wait")) + assert.Equal(t, "payment_methods", r.URL.Query().Get("expand")) + _, _ = io.WriteString(w, connectedWalletFixture) + case "/vaults/checkout/items/order-1/events": + assert.Equal(t, "60", r.URL.Query().Get("wait")) + assert.Equal(t, "event-before", r.URL.Query().Get("after")) + _, _ = io.WriteString(w, `[{"id":"event-next","name":"payment_failed","browser_id":"browser-1","created_at":"2026-09-01T00:00:00Z","data":{"reason":"declined","outcome_reason":"provider_error","provider_http_status":402,"provider_decline_code":"insufficient_funds","actual_currency":"usd","provider_response":{"card_number":"SECRET"}}}]`) + default: + t.Errorf("unexpected request: %s", r.URL) + } + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "wallet-1", "--wait", "60", "--expand", "payment_methods", "-o", "json") + require.NoError(t, err) + assert.JSONEq(t, connectedWalletFixture, out) + args := []string{"vaults", "items", "events", "checkout", "order-1", "--wait", "60", "--after", "event-before"} + out, _, err = executeVaultCommand(t, client, append(args, "-o", "json")...) + require.NoError(t, err) + assert.NotContains(t, out, "SECRET") + assert.Contains(t, out, "declined") + assert.Contains(t, out, `"provider_http_status": 402`) + assert.Contains(t, out, `"outcome_reason": "provider_error"`) + assert.Contains(t, out, `"actual_currency": "usd"`) + _, human, err := executeVaultCommand(t, client, args...) + require.NoError(t, err) + assert.Contains(t, human, "--after event-next") + assert.Contains(t, human, "payment_failed") + assert.Contains(t, human, "insufficient_funds") + assert.NotContains(t, human, "SECRET") +} + +func TestVaultDeleteResponses(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "project-test") + for _, path := range []string{"vaults delete checkout --yes", "vaults items delete checkout order-1 --yes"} { + for _, response := range []struct { + status int + code string + }{ + {204, ""}, {404, "not_found"}, {404, "project_not_found"}, {403, "forbidden"}, {409, "conflict"}, {500, "internal_error"}, + } { + t.Run(fmt.Sprintf("%s/%d/%s", path, response.status, response.code), func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodDelete, r.Method) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.status) + if response.code != "" { + _, _ = fmt.Fprintf(w, `{"code":%q,"message":"API error details"}`, response.code) + } + }) + out, human, err := executeVaultCommand(t, client, strings.Fields(path)...) + assert.Equal(t, 1, calls) + assert.Empty(t, out) + if response.status == http.StatusNoContent || response.status == http.StatusNotFound { + require.NoError(t, err) + assert.Contains(t, human, "Deleted or not found: vault") + assert.Contains(t, human, "checkout") + } else { + require.Error(t, err) + assert.Equal(t, response.code+": API error details", util.CleanedUpSdkError{Err: err}.Error()) + assert.Empty(t, human) + } + }) + } + } +} + +func TestVaultEmptyItemLists(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "project-test") + for _, path := range []string{"vaults items list checkout", "vaults items events checkout order-1"} { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `[]`) + }) + out, _, err := executeVaultCommand(t, client, append(strings.Fields(path), "-o", "json")...) + require.NoError(t, err) + assert.JSONEq(t, `[]`, out) + } +} + +func TestVaultOpenActionOnlyWhenRequested(t *testing.T) { + capturePtermOutput(t) + for _, actionURL := range []string{"", "https://provider.example/approval", "javascript:alert(1)", "https://user:secret@provider.example"} { + var item kernel.VaultItemUnion + body := strings.TrimSuffix(connectedWalletFixture, "}") + fmt.Sprintf(`,"action":{"name":"link_oauth","url":%q}}`, actionURL) + require.NoError(t, json.Unmarshal([]byte(body), &item)) + opened := "" + c := VaultsCmd{openURL: func(url string) error { opened = url; return nil }, prompter: interactive.NewPrompterWithTerminal(false)} + require.NoError(t, c.showItem(&item, "", false)) + assert.Empty(t, opened) + err := c.showItem(&item, "", true) + if strings.HasPrefix(actionURL, "https://provider.example") { + require.NoError(t, err) + assert.Equal(t, actionURL, opened) + } else if actionURL == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + assert.Empty(t, opened) + } + } +} diff --git a/go.mod b/go.mod index 45ca6fa..c00426c 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.95.0 + github.com/kernel/kernel-go-sdk v0.100.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 8c92c4e..84a83f5 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,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.95.0 h1:VoEneqrqqT5i3cO1L6faoHo4PqrndG0pAEs6Abivd5A= -github.com/kernel/kernel-go-sdk v0.95.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.100.0 h1:8RKNKk0js3BHdwEkDd0aPX17OEGSHVrI26akNUwttzU= +github.com/kernel/kernel-go-sdk v0.100.0/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=