diff --git a/.github/workflows/preview.yaml b/.github/workflows/preview.yaml new file mode 100644 index 00000000..fd952919 --- /dev/null +++ b/.github/workflows/preview.yaml @@ -0,0 +1,33 @@ +name: Preview CLI binaries + +on: + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - name: Download dependencies + run: go mod download + - name: Build preview archives + run: bash scripts/build-preview.sh + - uses: actions/upload-artifact@v4 + with: + name: kernel-preview-${{ github.event.pull_request.head.sha || github.sha }} + path: dist/preview/* + if-no-files-found: error + retention-days: 14 diff --git a/PREVIEW.md b/PREVIEW.md new file mode 100644 index 00000000..4688621e --- /dev/null +++ b/PREVIEW.md @@ -0,0 +1,40 @@ +# Preview binaries + +The `Preview CLI binaries` workflow builds pull requests automatically. +It also supports `workflow_dispatch` after the workflow exists on the default branch. +It builds the PR head, not GitHub's synthetic merge commit. Archives cover Linux, +macOS, and Windows on amd64 and arm64, with `SHA256SUMS`. The embedded version is +`0.0.0-preview.g`; the full commit is embedded as well. + +Download a run's artifact (GitHub authentication required): + +```sh +gh run list --repo kernel/cli --workflow preview.yaml --branch +gh run download --repo kernel/cli --name kernel-preview- --dir preview +cd preview +sha256sum -c SHA256SUMS +# macOS: shasum -a 256 -c SHA256SUMS +# Extract the archive matching your operating system and architecture. +tar -xzf kernel_0.0.0-preview.g_linux_amd64.tar.gz +./kernel --version +./kernel vaults credentials --help +./kernel vaults items invoke --help +``` + +Use the extracted binary explicitly rather than replacing the stable installation. +Set `KERNEL_BASE_URL` and `KERNEL_API_KEY` for your local/test environment before API +calls; do not assume the production API supports preview features. + +Artifacts expire after 14 days. No GitHub release, stable tag, npm package, Homebrew +formula, or production deployment is created. macOS binaries are unsigned; Windows +archives contain `kernel.exe`. Download on trusted machines and verify checksums. + +## Local builds + +Preview builds use the public Go SDK release pinned in `go.mod`; private SDK +repository access is not required. For a local cross-platform build, run: + +```sh +go mod download +bash scripts/build-preview.sh +``` diff --git a/README.md b/README.md index 96086b54..25f9021a 100644 --- a/README.md +++ b/README.md @@ -128,7 +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) +- **Vaults**: `create`, `list`, `get`, `credentials create/update`, `items list/get/events/invoke` (including `collect`, `fill`, and `prepare_checkout`), `wallets create/payment-methods`, `cards create/update` (display-safe public fields only) - **Projects**: `update` - **Org**: `limits get/set` - **Apps**: `list`, `history` @@ -270,7 +270,50 @@ Commands with JSON output support: ### Vaults -Vault commands **prepare and observe payment credentials; they do not submit merchant payments**. +Vault commands **collect user credentials and manage payment credentials; fill does not submit website forms**. + +#### User credentials + +Create a vault for the end user, attach it when creating a browser, then navigate to the +sensitive form. Define the observed fields without supplying values: + +```sh +kernel vaults create --name user-vault +kernel browsers create --vault user-vault +kernel vaults credentials create user-vault login --spec-file - <<'JSON' +{"description":"Hacker News","fields":{"username":{"type":"text","required":true,"sensitive":false},"password":{"type":"password","required":true,"sensitive":true}}} +JSON +kernel vaults items get user-vault login --wait 60 -o json +kernel vaults items invoke user-vault login fill --spec-file - <<'JSON' +{"browser_id":"","fields":[{"field":"username","selector":"#username"},{"field":"password","selector":"#password"}]} +JSON +``` + +Present the returned collection URL to the user before waiting for `ready`. It is a +bearer credential: share it only with that user. Readiness means required values are +populated, not that login succeeded. `fill` requires an already-open page and never +navigates or submits it. Optional `page_url` selects the exact page; cards require it. +Do not automatically retry failed/unknown fills or fall back to aliases. + +Use `credentials update --version --spec-file changes.json` +with a spec such as `{"fields":{"password":{"value":"replacement"}}}`. Keep actual +secrets in protected files or stdin, never shell arguments. Omission preserves values; +null or an empty string clears supported fields, including required text/email/password fields (returning them to pending collection). The form still requires nonempty required inputs. Field definitions cannot change. Stale versions fail, +without retries. `items invoke collect` reopens the full form without +clearing values; compare versions to observe edits to already-ready items. When an update +is bound to an earlier read, also pass `--expected-item-id ` to reject a replacement +item at the same key. Neither precondition is refreshed automatically. + +Do not use credential items to store, collect, or fill credit card data, including card numbers (PANs), security codes (CVV/CVC), or expiration dates. Use wallet and card item types for credit cards and payment checkout instead. + +Set `description` to the recognizable site name only, such as `Hacker News`, not `Hacker News sign-in credentials`. Set `sensitive: false` explicitly for ordinary usernames and email addresses. Reserve `sensitive: true` for passwords, API tokens, and TOTP seeds; the omitted default remains true for safety. + +Types are `text`, `email`, `password`, and `totp`. TOTP seeds must be provided through +create/update, never the form; only generated codes enter the browser. Unrestricted +browser access can read filled values. CLI output omits all stored credential values, +including non-sensitive values, and retains definitions, version, and `has_value`. +Credential spec input is capped at 128 KiB; write errors are redacted. + 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). @@ -474,7 +517,7 @@ card spec. Otherwise, the cardholder selects a card at approval. A reusable card #### Invoking item operations `items get` displays every `available_operations` entry's type and description, plus -an `items invoke` command retaining the selected project (replace `` for fill). +an `items invoke` command retaining the selected project (replace `` for fill or prepare_checkout). 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. @@ -485,17 +528,45 @@ advertised. The API controls availability. The CLI additionally refuses invocati actions in `recovery_required`, even if a stale action or operation was returned. `authorize` sends `{"type":"authorize"}` without `--params` and returns the updated item, -possibly with a required user action. `--open` is supported only for authorize. -The [API spec](https://api.onkernel.com/spec.yaml) also accepts `fill`, with its inputs in -`--params`. The positional operation supplies `type`; including `type` in params is rejected. +possibly with a required user action. `collect` is also parameterless and returns a credential +collection URL. `--open` is supported for authorize, collect, and prepare_checkout. +The [API spec](https://api.onkernel.com/spec.yaml) also accepts `fill` and `prepare_checkout`, with their inputs in +`--params` or `--spec-file ` (mutually exclusive, maximum 128 KiB). The positional +operation supplies `type`; including `type` in either input is rejected. Parameters must be a JSON object without unknown or duplicate properties. There is no operation `--spec` flag; wallet/card `--spec` flags remain unchanged. New parameterless operations can still be invoked by name when advertised. -##### Fill checkout fields +##### Prepare an AgentCard checkout + +For an unused AgentCard card, invoke `prepare_checkout` only when advertised: + +```bash +kernel vaults items invoke user-123 order-1 prepare_checkout --params '{"checkout":{"browser_id":"browser-session-id","merchant_origin":"https://shop.example","environment":"production"}}' --open +kernel vaults items get user-123 order-1 --wait 60 -o json +``` + +`--spec-file ` accepts the same JSON. `browser_id` is the active session with +this vault attached. `merchant_origin` is the canonical HTTPS origin of the top-level +merchant document, not the Square iframe; HTTP localhost is allowed for tests. +`environment` is `production` or `sandbox` and refers to Square, not the credential mode. + +Keep the approval page open. Poll until the item's status is `ready_to_submit`, then +submit native Pay before `state.preparation.expires_at`. Readiness lasts at most 30 +seconds, and polling does not extend it. The CLI displays the preparation ID, status, +browser, origin, environment, approval URL, and submission deadline. + +Each preparation is single-use, including after failure or expiry. A preparation +marked `consumed` has been claimed; it does not prove the payment settled or succeeded. +Do not automatically retry or switch checkout paths after an uncertain result. + +##### Fill browser fields -Fill is supported only when advertised by a ready Link card, not AgentCard. It writes stored -card data without returning the values or submitting checkout: +Fill supports credential items and ready Link cards when advertised by the API, not AgentCard. +Both use the same execution and outcome handling. Credential bindings use declared field names, +including TOTP fields, and must omit `format`. Credentials may omit `page_url` only when the API +can resolve a unique page. Card bindings require an exact HTTPS `page_url` and the card fields +listed below. It writes stored values without returning them or submitting the website form: ```bash kernel vaults items get checkout order-1 @@ -504,16 +575,16 @@ kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"browse - `browser_id` is a browser **session ID**, not a reusable browser name. It is sent unchanged; the CLI does not resolve names. -- `page_url` is the exact current top-level HTTPS URL, including path, query, and fragment, +- For cards, `page_url` is the exact current top-level HTTPS URL, including path, query, and fragment, without embedded credentials. It must match exactly one open page; no prefix/glob matching. - `fields` contains 1-32 bindings in write order. Each has `field` and a nonempty CSS `selector` targeting an editable input/select or its container. The API searches the selected page and descendants, including payment iframes. Do not supply frame IDs or literal values. -- Stored fields: `number`, `cvc`, `exp_month` (MM), `exp_year` (YYYY), `billing_name`, +- Stored card fields: `number`, `cvc`, `exp_month` (MM), `exp_year` (YYYY), `billing_name`, `billing_line1`, `billing_line2`, `billing_city`, `billing_state`, `billing_postal_code`, `billing_country`. Billing fields use the stored address without reformatting; request only needed fields. Missing requested billing data fails validation before browser writes. -- Combined `expiration` requires `format: "MM/YY"` or `"MM/YYYY"`. Other fields reject `format`. +- Combined card `expiration` requires `format: "MM/YY"` or `"MM/YYYY"`. Other fields reject `format`. - Optional `timeout_ms` is an integer from 1 to 30000 (default 10000), for the whole operation. Fill returns an execution result, **not an updated item**. Normal output shows zero-based @@ -530,8 +601,8 @@ errors are printed in fill results. Fill is non-atomic: execution stops at the first failed/unknown field and earlier writes are not rolled back. `filled` does not mean the site retained or accepted the value; `completed` -does not mean paid. Transport errors do not prove no writes occurred. Inspect the browser -before deciding what to do next. The CLI never retries, submits checkout, or falls back to +does not mean logged in or paid. Transport errors do not prove no writes occurred. Inspect the browser +before deciding what to do next. The CLI never retries, submits website forms, or falls back to aliases. Returned `state.aliases` remain an alternative for explicitly chosen egress-substitution integrations, not a recovery path after a failed or indeterminate fill. diff --git a/cmd/browsers.go b/cmd/browsers.go index 4c59108c..e33c8a2d 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -2974,7 +2974,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().StringArray("vault", nil, "Vault ID or name to attach for credential/card fill at creation (repeatable, max 20; incompatible with pools; see vaults --help)") 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)") diff --git a/cmd/vaults.go b/cmd/vaults.go index 6eb2a6ca..652d2577 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -14,7 +14,6 @@ import ( "github.com/kernel/cli/pkg/util" kernel "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" - "github.com/kernel/kernel-go-sdk/shared/constant" "github.com/pterm/pterm" ) @@ -193,7 +192,7 @@ func (c VaultsCmd) SaveCard(ctx context.Context, vault, key string, spec kernel. 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)) + item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCardVaultItemUpdateRequest: &kernel.VaultItemUpdateParamsBodyCardVaultItemUpdateRequest{Type: "card", Spec: spec}}, option.WithMaxRetries(0)) } else { item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCard: &kernel.VaultItemUpsertParamsBodyCard{Spec: spec}}, option.WithMaxRetries(0)) } @@ -203,13 +202,16 @@ func (c VaultsCmd) SaveCard(ctx context.Context, vault, key string, spec kernel. return c.showItem(item, output, false) } -func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, params *vaultFillParams, output string, open bool) error { +func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, params *vaultOperationParams, output string, open bool) error { if strings.TrimSpace(operation) == "" { return fmt.Errorf("operation must not be empty") } - if operation == "fill" && (params == nil || open) { + if operation == "fill" && (params == nil || params.Fill == nil || open) { return fmt.Errorf("fill requires --params and does not support --open") } + if operation == "prepare_checkout" && (params == nil || params.Checkout == nil) { + return fmt.Errorf("prepare_checkout requires checkout parameters") + } item, err := c.vaults.Items.Get(ctx, key, kernel.VaultItemGetParams{IDOrName: vault}, option.WithMaxRetries(0)) if err != nil { if operation == "fill" { @@ -241,15 +243,31 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par return fmt.Errorf("operation %q is not advertised in available_operations; inspect the item", operation) } if operation == "fill" { - return c.fill(ctx, vault, key, params, output) + if err := validateVaultFillItem(params.Fill, item); err != nil { + return err + } + return c.fill(ctx, vault, key, params.Fill, output) } - // Preserve support for other advertised parameterless operations. - authorize := kernel.VaultItemPerformOperationParamsBodyAuthorize{Type: constant.Authorize(operation)} - response, err := c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, OfAuthorize: &authorize}, option.WithMaxRetries(0)) + request := kernel.VaultItemPerformOperationParams{IDOrName: vault} + if operation == "prepare_checkout" { + if item.Type != "card" || item.Spec.Provider != "agentcard" { + return fmt.Errorf("prepare_checkout requires an AgentCard card") + } + request.OfPrepareCheckout = &kernel.PrepareCheckoutVaultItemOperationRequestParam{Type: "prepare_checkout", Checkout: *params.Checkout} + } else if operation == "collect" { + request.OfCollect = &kernel.CollectVaultItemOperationRequestParam{Type: "collect"} + } else { + // Preserve support for other advertised parameterless operations. + request.OfAuthorize = &kernel.AuthorizeVaultItemOperationRequestParam{Type: kernel.AuthorizeVaultItemOperationRequestType(operation)} + } + response, err := c.vaults.Items.PerformOperation(ctx, key, request, option.WithMaxRetries(0)) if err != nil { + if item.Type == "credential" { + return vaultCredentialError(err) + } return util.CleanedUpSdkError{Err: err} } - if response == nil || (response.Type != "card" && response.Type != "wallet") { + if response == nil || (response.Type != "card" && response.Type != "wallet" && response.Type != "credential") { return fmt.Errorf("unexpected vault operation response; inspect the item and do not retry") } var updated kernel.VaultItemUnion diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index fa315e37..48b6ff3b 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -51,8 +51,22 @@ func vaultPreRun(cmd *cobra.Command, args []string) error { 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. + Use: "vaults", Aliases: []string{"vault"}, Short: "Collect user credentials and manage payment credentials", + Long: `Collect user credentials and manage payment credentials; fill never submits website forms. + +Do not use credential items to store, collect, or fill credit card data. +Use wallet and card item types for credit cards and payment checkout instead. + +User credential flow: +1. Create a vault per end user and create a browser with --vault . +2. Navigate to a sensitive form and define its fields with credentials create --spec-file. +3. Present the returned collection URL to the user. Poll items get --wait 60 for ready. +4. Use items invoke fill --spec-file with browser_id and field selectors. +Use credentials update --version for edits, or items invoke collect to reopen the form. +Credential values belong in protected files/stdin, never command-line arguments. +See credentials --help and items invoke --help for examples. + +Payment credential flow: Optionally select a project with --project or KERNEL_PROJECT. Otherwise, the API resolves the project from your credentials and its defaults. @@ -106,14 +120,14 @@ JSON output preserves returned public fields but omits unknown/opaque provider d addVaultJSONOutputFlag(get) cmd.AddCommand(create, list, get, newVaultDeleteCommand(false)) - items := &cobra.Command{Use: "items", Short: "Inspect vault item state, actions, aliases, and outcomes"} + items := &cobra.Command{Use: "items", Short: "Inspect readiness and collection URLs, or invoke collect/fill", Long: "Use get --wait 60 to observe readiness and get -o json for schema/version/presence.\nUse invoke collect to obtain a collection URL, or invoke fill --spec-file to fill a browser.\nCreate and edit credentials with vaults credentials; payment items use wallets/cards."} 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.\nrecovery_required stops waiting and means unresolved, not declined or expired.\nReconcile with the provider or support; do not retry, delete, or replace the payment.", + Long: "Get item state, available operations, provider actions, and returned checkout aliases.\n--wait is a single bounded server-side observation, not a retry or a guarantee of readiness.\nAn item still pending after the wait is returned as-is; ready means populated for credentials, not logged in or paid.\nFor credential edits on an already-ready item, compare versions without --wait. Stored field values are omitted from CLI output.\nrecovery_required stops waiting and means unresolved, not declined or expired.\nReconcile with the provider or support; do not retry, delete, or replace the payment.", RunE: func(cmd *cobra.Command, args []string) error { wait, _ := cmd.Flags().GetInt64("wait") expand, _ := cmd.Flags().GetStringSlice("expand") @@ -136,36 +150,59 @@ JSON output preserves returned public fields but omits unknown/opaque provider d 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. -Read its description with items get before invoking; follow any approval requirements. -Authorize sends {"type":"authorize"} without --params and returns an updated item; ---open opens its returned HTTPS action URL. -Fill requires --params JSON with browser_id (session ID, not name), exact HTTPS -page_url, and 1-32 fields. Each binding has field and selector; expiration also -requires format MM/YY or MM/YYYY. Stored fields: number, cvc, exp_month (MM), +collect returns a time-scoped URL for the full credential form without clearing values. +authorize sends {"type":"authorize"} for payment authorization. +Read the operation description and follow any approval requirements before invoking. +fill requires --params JSON or --spec-file with browser_id (session ID, not name) +and 1-32 ordered fields (field, selector). Do not include type, values, or frame IDs. +The vault must already be attached to the browser. page_url selects an existing page; +fill never navigates. Credentials use declared field names, must omit format, and may +omit page_url only when the API can resolve a unique page. TOTP codes stay server-generated. +Cards require an exact HTTPS page_url. Stored fields: number, cvc, exp_month (MM), exp_year (YYYY), billing_name, billing_line1, billing_line2, billing_city, -billing_state, billing_postal_code, billing_country. Optional timeout_ms is 1-30000 -(default 10000). Do not include type, values, or frame IDs in --params. -Fill is available only when advertised by a ready Link card, not AgentCard. -The API searches the selected page and descendant frames, including payment iframes. -Fill returns value-free per-field outcomes, not an updated item. Completed exits 0; -failed/unknown exit nonzero while preserving the result in -o json. -Fill is not atomic: earlier writes are not rolled back. Transport errors do not -prove no writes occurred. No automatic retries, alias fallback, or form submission. -Inspect the browser before deciding what to do next; completed does not mean paid.`, - Example: ` kernel vaults items get checkout order-1 - kernel vaults items invoke checkout order-1 authorize --open - kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"browser-session-id","page_url":"https://shop.example/checkout","fields":[{"field":"number","selector":"#card-number"},{"field":"expiration","format":"MM/YY","selector":"#expiry"},{"field":"cvc","selector":"#security-code"}],"timeout_ms":10000}' -o json`, +billing_state, billing_postal_code, billing_country. expiration requires format MM/YY +or MM/YYYY. Optional timeout_ms is 1-30000 (default 10000). +The API searches the page and descendant frames, including payment iframes. +Fill is available for credential items and ready Link cards when advertised, not AgentCard. +Fill never submits forms. completed means fields were filled, not website acceptance. +failed may leave partial writes; unknown quarantines the browser. Never automatically +retry or fall back to aliases. Requests are not automatically retried. +prepare_checkout requires checkout.browser_id, checkout.merchant_origin (canonical HTTPS +origin of the top-level merchant page), and checkout.environment (production or sandbox). +Use only when advertised for an AgentCard card. Keep the returned approval page open, +poll until ready_to_submit, then submit native Pay before preparation.expires_at. +Preparations are single-use, including after failure or expiry; never retry automatically. +collect/authorize/prepare_checkout may use --open. Fill returns value-free per-field outcomes; +completed exits 0, failed/unknown exit nonzero with valid JSON retained on stdout in -o json.`, + Example: ` kernel vaults items invoke user-vault login collect + kernel vaults items invoke user-vault login fill --spec-file - <<'JSON' +{"browser_id":"","fields":[{"field":"username","selector":"#username"},{"field":"password","selector":"#password"}]} +JSON + kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"browser-session-id","page_url":"https://shop.example/checkout","fields":[{"field":"number","selector":"#card-number"}]}' -o json`, RunE: func(cmd *cobra.Command, args []string) error { open, _ := cmd.Flags().GetBool("open") raw, _ := cmd.Flags().GetString("params") - params, err := parseVaultOperationParams(args[2], raw, cmd.Flags().Changed("params"), cmd.Flags().Changed("open")) + paramsSet := cmd.Flags().Changed("params") + if cmd.Flags().Changed("spec-file") { + if args[2] != "fill" && args[2] != "prepare_checkout" { + return fmt.Errorf("--spec-file is only supported for fill and prepare_checkout") + } + data, err := readVaultSpecFile(cmd) + if err != nil { + return err + } + raw, paramsSet = string(data), true + } + params, err := parseVaultOperationParams(args[2], raw, paramsSet, cmd.Flags().Changed("open")) if err != nil { return err } return getVaultsHandler(cmd).Invoke(cmd.Context(), args[0], args[1], args[2], params, vaultOutput(cmd), open) }} - invoke.Flags().String("params", "", "Operation-specific JSON object for fill; omit type (supplied by )") - invoke.Flags().Bool("open", false, "Open a returned HTTPS action URL for authorize") + invoke.Flags().String("params", "", "Fill or prepare_checkout parameters JSON (maximum 128 KiB); omit type and credential values") + invoke.Flags().String("spec-file", "", "Fill or prepare_checkout parameters JSON file (use '-' for stdin; maximum 128 KiB)") + invoke.MarkFlagsMutuallyExclusive("params", "spec-file") + invoke.Flags().Bool("open", false, "Open a returned HTTPS action URL in your browser") addVaultJSONOutputFlag(invoke) items.AddCommand(itemList, itemGet, itemEvents, invoke, newVaultDeleteCommand(true)) @@ -208,7 +245,7 @@ Inspect the browser before deciding what to do next; completed does not mean pai cards := &cobra.Command{Use: "cards", Short: "Configure card requests"} cards.AddCommand(newVaultCardCommand(false), newVaultCardCommand(true)) - cmd.AddCommand(items, wallets, cards) + cmd.AddCommand(items, wallets, cards, newVaultCredentialsCommand()) return cmd } diff --git a/cmd/vaults_credential_guidance_test.go b/cmd/vaults_credential_guidance_test.go new file mode 100644 index 00000000..00142808 --- /dev/null +++ b/cmd/vaults_credential_guidance_test.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCredentialHumanGuidance(t *testing.T) { + for _, operation := range []string{"create", "get", "collect"} { + for _, status := range []string{"pending_collection", "ready"} { + t.Run(operation+"/"+status, func(t *testing.T) { + fixture := strings.Replace(credentialFixture, "pending_collection", status, 1) + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, fixture) + }) + args := []string{"vaults", "items", "get", "user", "login"} + switch operation { + case "create": + args = []string{"vaults", "credentials", "create", "user", "login", "--spec-file", credentialSpecFile(t, `{"fields":{"password":{"type":"password"}}}`)} + case "collect": + args = []string{"vaults", "items", "invoke", "user", "login", "collect"} + } + stdout, human, err := executeVaultCommand(t, client, args...) + require.NoError(t, err) + output := stdout + human + assert.Contains(t, output, "Share the collection URL with the user") + assert.Contains(t, output, "items get --wait 60") + assert.Contains(t, output, "compare versions without --wait") + assert.Contains(t, output, "not that login succeeded") + assert.Contains(t, output, "Do not use credential items for credit card data") + assert.Contains(t, output, "Use wallet and card item types") + assert.Contains(t, output, "Available operation: fill") + assert.NotContains(t, output, "with the provider") + assert.NotContains(t, output, "OAuth codes") + assert.NotContains(t, output, "never-print") + }) + } + } +} diff --git a/cmd/vaults_credential_steering_test.go b/cmd/vaults_credential_steering_test.go new file mode 100644 index 00000000..e6613eaf --- /dev/null +++ b/cmd/vaults_credential_steering_test.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCredentialEmptyStringUpdate(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPatch, r.Method) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"credential","version":2,"spec":{"fields":{"password":{"value":""}}}}`, string(body)) + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, credentialFixture) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "credentials", "update", "user", "login", "--version", "2", "--spec-file", credentialSpecFile(t, `{"fields":{"password":{"value":""}}}`), "-o", "json") + require.NoError(t, err) +} + +func TestCredentialHelpSteering(t *testing.T) { + cmd, _, err := newVaultsCommand().Find([]string{"credentials", "create"}) + require.NoError(t, err) + assert.Contains(t, cmd.Long, "Do not use credential items to store, collect, or fill credit card data") + assert.Contains(t, strings.Join(strings.Fields(cmd.Long), " "), "Use wallet and card item types for credit cards and payment checkout instead") + assert.Contains(t, newVaultsCommand().Long, "Use wallet and card item types") + assert.Contains(t, cmd.Long, "recognizable site name only") + assert.Contains(t, cmd.Long, "sensitive:false explicitly for ordinary usernames and email addresses") + assert.Contains(t, cmd.Example, `"description":"Hacker News"`) + assert.Contains(t, cmd.Example, `"sensitive":false`) +} diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go new file mode 100644 index 00000000..5c215cdc --- /dev/null +++ b/cmd/vaults_credentials.go @@ -0,0 +1,133 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/spf13/cobra" +) + +const vaultCredentialHelp = `Create credentials from the fields observed on a website. + +Do not use credential items to store, collect, or fill credit card data, including +card numbers (PANs), security codes (CVV/CVC), or expiration dates. Use wallet and +card item types for credit cards and payment checkout instead. + +First create a vault for the end user and attach it with browsers create --vault. +Use a protected JSON file or stdin, never secret values in shell arguments. +The spec contains description and fields keyed by name. Field types are text, +email, password, and totp; definitions accept required, sensitive, and value. +Set description to the recognizable site name only, e.g. "Hacker News", not +"Hacker News sign-in credentials". This text is the user-facing form title. +Set sensitive:false explicitly for ordinary usernames and email addresses. +Reserve sensitive:true for secrets such as passwords, API tokens, and TOTP seeds. +Password and totp must be sensitive. Omitted sensitive defaults to true for safety. +Omit required values to receive a collection URL to present to the user. +Poll items get --wait 60 until state.status is ready, then use items invoke fill. +Ready means populated, not a successful login. An agent controlling the browser +can read filled values. TOTP seeds must not be collected through the hosted form. +Get/list output includes definitions and has_value, not stored field values. +Collection URLs are bearer credentials: share only with the intended user.` + +func newVaultCredentialsCommand() *cobra.Command { + group := &cobra.Command{Use: "credentials", Short: "Collect, update, and fill user credentials", Long: vaultCredentialHelp} + for _, update := range []bool{false, true} { + name, short := "create", "Create a credential and return its collection URL" + if update { + name, short = "update", "Update credential values or description using an expected version" + } + cmd := &cobra.Command{Use: name + " --spec-file ", Short: short, Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, Long: vaultCredentialHelp, + RunE: func(cmd *cobra.Command, args []string) error { + data, err := readVaultSpecFile(cmd) + if err != nil { + return err + } + version, _ := cmd.Flags().GetInt64("version") + open, _ := cmd.Flags().GetBool("open") + expectedID, _ := cmd.Flags().GetString("expected-item-id") + if cmd.Flags().Changed("expected-item-id") && strings.TrimSpace(expectedID) == "" { + return fmt.Errorf("--expected-item-id must not be empty") + } + return getVaultsHandler(cmd).saveCredential(cmd.Context(), args[0], args[1], data, update, version, expectedID, vaultOutput(cmd), open) + }, + } + if update { + cmd.Long += "\nUpdate preserves omitted fields, replaces nonempty string values, and clears supported values with null or an empty string. Clearing a required text/email/password field returns pending_collection; form submissions still require a nonempty value.\nField definitions are immutable. Do not automatically retry version conflicts." + cmd.Flags().Int64("version", 0, "Expected version from items get (required; never auto-refreshed)") + _ = cmd.MarkFlagRequired("version") + cmd.Flags().String("expected-item-id", "", "Immutable item ID from the original read; reject an update if the key now refers to a replacement item") + cmd.Example = " kernel vaults credentials update user-vault login --version 2 --spec-file changes.json" + } else { + cmd.Example = ` kernel vaults credentials create user-vault login --spec-file - <<'JSON' +{"description":"Hacker News","fields":{"username":{"type":"text","required":true,"sensitive":false},"password":{"type":"password","required":true,"sensitive":true}}} +JSON` + } + cmd.Flags().String("spec-file", "", "Credential spec JSON file (use '-' for stdin; maximum 128 KiB)") + _ = cmd.MarkFlagRequired("spec-file") + cmd.Flags().Bool("open", false, "Open the returned HTTPS collection URL") + addVaultJSONOutputFlag(cmd) + group.AddCommand(cmd) + } + return group +} + +func readVaultSpecFile(cmd *cobra.Command) ([]byte, error) { + path, _ := cmd.Flags().GetString("spec-file") + if path == "" { + return nil, fmt.Errorf("--spec-file is required (use '-' for stdin)") + } + var reader io.Reader = cmd.InOrStdin() + if path != "-" { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("could not open --spec-file") + } + defer f.Close() + reader = f + } + const limit = 128 * 1024 + data, err := io.ReadAll(io.LimitReader(reader, limit+1)) + if err != nil || len(data) > limit { + return nil, fmt.Errorf("could not read --spec-file (maximum 128 KiB)") + } + var object map[string]json.RawMessage + if json.Unmarshal(data, &object) != nil || object == nil { + return nil, fmt.Errorf("--spec-file must contain a JSON object") + } + return data, nil +} + +func (c VaultsCmd) saveCredential(ctx context.Context, vault, key string, data []byte, update bool, version int64, expectedID, output string, open bool) error { + var item *kernel.VaultItemUnion + var err error + if update { + if version < 1 { + return fmt.Errorf("--version must be positive") + } + var spec kernel.CredentialVaultItemSpecUpdateParam + if json.Unmarshal(data, &spec) != nil { + return fmt.Errorf("invalid credential update spec") + } + request := kernel.CredentialVaultItemUpdateRequestParam{Type: "credential", Version: version, Spec: spec} + if expectedID != "" { + request.ExpectedItemID = kernel.String(expectedID) + } + item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCredentialVaultItemUpdateRequest: &request}, option.WithMaxRetries(0)) + } else { + var spec kernel.CredentialVaultItemSpecInputParam + if json.Unmarshal(data, &spec) != nil || len(spec.Fields) == 0 { + return fmt.Errorf("credential spec requires fields") + } + item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCredential: &kernel.CredentialVaultItemRequestParam{Type: "credential", Spec: spec}}, option.WithMaxRetries(0)) + } + if err != nil { + return vaultCredentialError(err) + } + return c.showItem(item, output, open) +} diff --git a/cmd/vaults_credentials_test.go b/cmd/vaults_credentials_test.go new file mode 100644 index 00000000..8dc6ef2e --- /dev/null +++ b/cmd/vaults_credentials_test.go @@ -0,0 +1,211 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const credentialFixture = `{"id":"credential-1","key":"login","type":"credential","version":2,"spec":{"description":"Website login","fields":{"password":{"type":"password","required":true,"sensitive":true,"value":"never-print"}}},"state":{"status":"pending_collection","fields":{"password":{"has_value":false,"value":"never-print"}}},"action":{"name":"collect","url":"https://vault.kernel.sh/collect#token=item.random","expires_at":"2026-10-01T00:00:00Z"},"available_operations":[{"type":"collect","description":"Open the form"},{"type":"fill","description":"Fill the form"}],"available_expansions":[]}` + +func credentialSpecFile(t *testing.T, data string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "spec.json") + require.NoError(t, os.WriteFile(path, []byte(data), 0600)) + return path +} + +func TestCredentialCreateAndUpdate(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, update := range []bool{false, true} { + t.Run(fmt.Sprint(update), func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, "/vaults/user/items/login", r.URL.Path) + var body map[string]json.RawMessage + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.JSONEq(t, `"credential"`, string(body["type"])) + if update { + assert.Equal(t, "PATCH", r.Method) + assert.JSONEq(t, `2`, string(body["version"])) + assert.JSONEq(t, `"credential-1"`, string(body["expected_item_id"])) + assert.JSONEq(t, `{"fields":{"password":{"value":null}}}`, string(body["spec"])) + } else { + assert.Equal(t, "PUT", r.Method) + assert.JSONEq(t, `{"fields":{"password":{"type":"password","required":true}}}`, string(body["spec"])) + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, credentialFixture) + }) + args := []string{"vaults", "credentials", "create", "user", "login", "--spec-file", credentialSpecFile(t, `{"fields":{"password":{"type":"password","required":true}}}`), "-o", "json"} + if update { + args[2] = "update" + args[6] = credentialSpecFile(t, `{"fields":{"password":{"value":null}}}`) + args = append(args, "--version", "2", "--expected-item-id", "credential-1") + } + out, _, err := executeVaultCommand(t, client, args...) + require.NoError(t, err) + assert.Equal(t, 1, calls) + assert.NotContains(t, out, "never-print") + assert.Contains(t, out, `"has_value": false`) + assert.Contains(t, out, `"version": 2`) + assert.Contains(t, out, "#token=item.random") + }) + } +} + +func TestCredentialWriteErrorsAreRedactedAndNotRetried(t *testing.T) { + for _, status := range []int{400, 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") + w.WriteHeader(status) + io.WriteString(w, `{"message":"secret-echo"}`) + }) + c := VaultsCmd{vaults: &client.Vaults} + err := c.saveCredential(context.Background(), "user", "login", []byte(`{"fields":{"password":{"type":"password","value":"secret-echo"}}}`), false, 0, "", "json", false) + require.Error(t, err) + assert.NotContains(t, err.Error(), "secret-echo") + assert.Equal(t, 1, calls) + }) + } +} + +func TestCredentialCollectAndFill(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, status := range []string{"collect", "completed", "failed", "unknown"} { + t.Run(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 calls == 1 { + io.WriteString(w, credentialFixture) + return + } + assert.Equal(t, "POST", r.Method) + data, err := io.ReadAll(r.Body) + require.NoError(t, err) + if status == "collect" { + assert.JSONEq(t, `{"type":"collect"}`, string(data)) + io.WriteString(w, credentialFixture) + return + } + assert.JSONEq(t, `{"type":"fill","browser_id":"browser-1","fields":[{"field":"password","selector":"#password"}]}`, string(data)) + fieldStatus := status + if status == "completed" { + fieldStatus = "filled" + } + io.WriteString(w, fmt.Sprintf(`{"type":"fill","status":%q,"fields":[{"index":0,"status":%q}],"secret":"never-print"}`, status, fieldStatus)) + }) + op := "fill" + if status == "collect" { + op = "collect" + } + args := []string{"vaults", "items", "invoke", "user", "login", op, "-o", "json"} + if op == "fill" { + args = append(args, "--spec-file", credentialSpecFile(t, `{"browser_id":"browser-1","fields":[{"field":"password","selector":"#password"}]}`)) + } + out, _, err := executeVaultCommand(t, client, args...) + if status == "failed" || status == "unknown" { + require.ErrorContains(t, err, "fill "+status) + assert.True(t, json.Valid([]byte(out))) + } else { + require.NoError(t, err) + } + assert.Equal(t, 2, calls) + assert.NotContains(t, out, "never-print") + }) + } +} + +func TestCredentialOperationDoesNotRetainOldAction(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + var response map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(credentialFixture), &response)) + delete(response, "action") + fresh, err := json.Marshal(response) + require.NoError(t, err) + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + if calls == 1 { + io.WriteString(w, credentialFixture) + } else { + w.Write(fresh) + } + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "user", "login", "collect", "-o", "json") + require.NoError(t, err) + assert.NotContains(t, out, "#token=") +} + +func TestCredentialFillErrorCodes(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, code := range []string{"ambiguous_selector", "secret-echo"} { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + if calls == 1 { + io.WriteString(w, credentialFixture) + return + } + w.WriteHeader(400) + fmt.Fprintf(w, `{"code":%q,"message":"secret-echo"}`, code) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "user", "login", "fill", "--spec-file", credentialSpecFile(t, `{"browser_id":"browser-1","fields":[{"field":"password","selector":"#password"}]}`)) + require.Error(t, err) + assert.NotContains(t, err.Error(), "secret-echo") + assert.Equal(t, 2, calls) + if code == "ambiguous_selector" { + assert.Contains(t, err.Error(), code) + } + } +} + +func TestCredentialFillRejectsUnsafeOutcomes(t *testing.T) { + for _, raw := range []string{ + `{"type":"fill","status":"secret-echo","fields":[]}`, + `{"type":"fill","status":"completed","fields":[{"index":0,"status":"filled","error_code":"secret-echo"}]}`, + `{"type":"fill","status":"completed","fields":[]}`, + } { + result, err := parseVaultFillResult(json.RawMessage(raw), 1) + require.Error(t, err) + assert.Nil(t, result) + assert.NotContains(t, err.Error(), "secret-echo") + } +} + +func TestCredentialDiscoveryAndInvalidInput(t *testing.T) { + for _, path := range []string{"credentials", "credentials create", "credentials update", "items", "items invoke"} { + cmd, _, err := newVaultsCommand().Find(strings.Fields(path)) + require.NoError(t, err) + assert.NotEmpty(t, cmd.Long) + } + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("invalid input reached API") }) + for _, body := range []string{"null", "[]", "{} {}", strings.Repeat("x", 128*1024+1)} { + _, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "user", "login", "--spec-file", credentialSpecFile(t, body)) + require.Error(t, err) + } + cmd, _, err := newVaultsCommand().Find([]string{"credentials", "create"}) + require.NoError(t, err) + cmd.Flags().Set("spec-file", "-") + cmd.SetIn(strings.NewReader(`{"fields":{}}`)) + data, err := readVaultSpecFile(cmd) + require.NoError(t, err) + assert.JSONEq(t, `{"fields":{}}`, string(data)) +} diff --git a/cmd/vaults_fill.go b/cmd/vaults_fill.go index 7f63c6c0..a5a7b4ee 100644 --- a/cmd/vaults_fill.go +++ b/cmd/vaults_fill.go @@ -34,6 +34,15 @@ const vaultFillUncertain = "browser fields may have been written; inspect the br func vaultFillRequestError(err error) error { var apiErr *kernel.Error if errors.As(err, &apiErr) { + var body struct { + Code string `json:"code"` + } + if json.Unmarshal([]byte(apiErr.RawJSON()), &body) == nil { + switch body.Code { + case "invalid_request", "invalid_selector", "duplicate_target", "timeout", "target_changed", "page_not_found", "ambiguous_page", "element_not_found", "ambiguous_selector", "element_not_editable", "option_not_found", "field_unavailable", "conflict", "destination_denied", "execution_failed": + return fmt.Errorf("fill failed: %s (HTTP %d); %s", body.Code, apiErr.StatusCode, vaultFillUncertain) + } + } return fmt.Errorf("fill request failed (HTTP %d); %s", apiErr.StatusCode, vaultFillUncertain) } // Do not wrap SDK/transport errors: they can contain request or response data, @@ -44,17 +53,19 @@ func vaultFillRequestError(err error) error { func (c VaultsCmd) fill(ctx context.Context, vault, key string, params *vaultFillParams, output string) error { request := kernel.FillVaultItemOperationRequestParam{ BrowserID: params.BrowserID, - PageURL: params.PageURL, Type: kernel.FillVaultItemOperationRequestTypeFill, - Fields: make([]kernel.VaultCardFillFieldUnionParam, 0, len(params.Fields)), + Fields: make([]kernel.VaultFillFieldParam, 0, len(params.Fields)), + } + if params.PageURL != "" { + request.PageURL = kernel.Opt(params.PageURL) } if params.TimeoutMS != nil { request.TimeoutMs = kernel.Opt(int64(*params.TimeoutMS)) } for _, field := range params.Fields { - binding := kernel.VaultCardFillFieldParamOfVaultCardFillFieldVaultCardStoredFillField(field.Field, field.Selector) - if field.Field == "expiration" { - binding = kernel.VaultCardFillFieldParamOfVaultCardFillFieldVaultCardExpirationFillField(field.Field, field.Format, field.Selector) + binding := kernel.VaultFillFieldParam{Field: field.Field, Selector: field.Selector} + if field.Format != "" { + binding.Format = kernel.VaultFillFieldFormat(field.Format) } request.Fields = append(request.Fields, binding) } @@ -81,7 +92,7 @@ func (c VaultsCmd) fill(ctx context.Context, vault, key string, params *vaultFil } PrintTableNoPad(rows, true) if result.Status == "completed" { - pterm.Println("Fields filled; this does not confirm payment or merchant acceptance.") + pterm.Println("Fields filled; this does not confirm website acceptance or form submission.") } else { pterm.Println(vaultFillUncertain) } diff --git a/cmd/vaults_fill_credentials_test.go b/cmd/vaults_fill_credentials_test.go new file mode 100644 index 00000000..0450a70f --- /dev/null +++ b/cmd/vaults_fill_credentials_test.go @@ -0,0 +1,116 @@ +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const readyFillCredentialFixture = `{"id":"credential-1","type":"credential","spec":{"fields":{"expiration":{"type":"password"},"custom field":{"type":"text"},"otp":{"type":"totp"}}},"state":{"status":"ready"},"available_operations":[{"type":"fill","description":"Fill credential fields."}]}` + +func TestVaultFillBothItemTypesAndInputs(t *testing.T) { + for _, input := range []string{"params", "spec-file"} { + for _, test := range []struct{ name, item, params, result string }{ + {"card", readyFillCardFixture, fillParamsFixture, completedFillFixture}, + {"credential", readyFillCredentialFixture, `{"browser_id":"browser-id","fields":[{"field":"expiration","selector":"#password"},{"field":"custom field","selector":"#custom"},{"field":"otp","selector":"#code"}]}`, completedFillFixture}, + {"credential URL", readyFillCredentialFixture, `{"browser_id":"browser-id","page_url":"http://localhost/login","fields":[{"field":"expiration","selector":"#password"}]}`, `{"type":"fill","status":"completed","fields":[{"index":0,"status":"filled"}]}`}, + } { + t.Run(input+"/"+test.name, 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, test.item) + return + } + require.Equal(t, http.MethodPost, r.Method) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"fill",`+test.params[1:], string(body)) + io.WriteString(w, test.result) + }) + value := test.params + if input == "spec-file" { + value = credentialSpecFile(t, value) + } + out, human, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "vault", "item", "fill", "--"+input, value, "-o", "json") + require.NoError(t, err) + assert.JSONEq(t, test.result, out) + assert.Empty(t, human) + assert.Equal(t, 2, calls) + }) + } + } +} + +func TestVaultCredentialFillValidation(t *testing.T) { + for _, input := range []string{"params", "spec-file"} { + for _, params := range []string{ + `{"browser_id":"id","fields":[{"field":"unknown","selector":"#field"}]}`, + `{"browser_id":"id","fields":[{"field":"expiration","selector":"#field","format":"MM/YY"}]}`, + `{"browser_id":"id","fields":[{"field":"custom field","selector":"#field","format":"MM/YYYY"}]}`, + `{"browser_id":"id","fields":[{"field":"expiration","selector":"#field","value":"secret-sentinel"}]}`, + `{"browser_id":"id","browser_id":"secret-sentinel","fields":[{"field":"expiration","selector":"#field"}]}`, + } { + t.Run(input+"/"+params, func(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, readyFillCredentialFixture) + }) + value := params + if input == "spec-file" { + value = credentialSpecFile(t, params) + } + out, human, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "vault", "item", "fill", "--"+input, value, "-o", "json") + require.Error(t, err) + assert.NotContains(t, err.Error(), "secret-sentinel") + assert.Empty(t, out+human) + }) + } + } +} + +func TestVaultFillInputLimits(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid input reached API") }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "vault", "item", "fill", "--params", fillParamsFixture, "--spec-file", credentialSpecFile(t, fillParamsFixture)) + require.Error(t, err) + _, _, err = executeVaultCommand(t, client, "vaults", "items", "invoke", "vault", "item", "fill", "--params", strings.Repeat(" ", 128*1024+1)) + require.Error(t, err) +} + +func TestCredentialFillCLIOutcomes(t *testing.T) { + for _, result := range []string{completedFillFixture, failedFillFixture, unknownFillFixture} { + t.Run(result, func(t *testing.T) { + var posts atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + io.WriteString(w, readyFillCredentialFixture) + return + } + posts.Add(1) + io.WriteString(w, result) + })) + defer server.Close() + out, stderr, exit := runVaultFillCLI(t, server.URL, "fill", "--params", `{"browser_id":"id","fields":[{"field":"expiration","selector":"#password"},{"field":"custom field","selector":"#custom"},{"field":"otp","selector":"#code"}]}`, "-o", "json") + assert.True(t, json.Valid([]byte(out))) + assert.JSONEq(t, result, out) + assert.Empty(t, stderr) + expectedExit := 1 + if result == completedFillFixture { + expectedExit = 0 + } + assert.Equal(t, expectedExit, exit) + assert.EqualValues(t, 1, posts.Load()) + }) + } +} diff --git a/cmd/vaults_fill_test.go b/cmd/vaults_fill_test.go index b0531f63..f9ca6cc7 100644 --- a/cmd/vaults_fill_test.go +++ b/cmd/vaults_fill_test.go @@ -26,7 +26,12 @@ const failedFillFixture = `{"type":"fill","status":"failed","fields":[{"index":0 const unknownFillFixture = `{"type":"fill","status":"unknown","fields":[{"index":0,"status":"filled"},{"index":1,"status":"unknown","error_code":"timeout"},{"index":2,"status":"not_attempted"}]}` func TestVaultFillParamsValidation(t *testing.T) { - client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid params reached API") }) + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + // Type-specific checks require the current item, but must never invoke fill. + require.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, readyFillCardFixture) + }) replace := func(old, value string) string { return strings.Replace(fillParamsFixture, old, value, 1) } for name, raw := range map[string]string{ "empty": "", "null": "null", "array": "[]", "scalar": `"credential-sentinel"`, diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go index e519a6c8..8dbfe706 100644 --- a/cmd/vaults_operation_params.go +++ b/cmd/vaults_operation_params.go @@ -8,11 +8,18 @@ import ( "regexp" "slices" "strings" + + kernel "github.com/kernel/kernel-go-sdk" ) +type vaultOperationParams struct { + Fill *vaultFillParams + Checkout *kernel.VaultCheckoutContextParam +} + type vaultFillParams struct { BrowserID string `json:"browser_id"` - PageURL string `json:"page_url"` + PageURL string `json:"page_url,omitempty"` Fields []vaultFillField `json:"fields"` TimeoutMS *int `json:"timeout_ms,omitempty"` } @@ -23,11 +30,11 @@ type vaultFillField struct { Format string `json:"format,omitempty"` } -var vaultFillPageURLPattern = regexp.MustCompile(`^https://[^/?#@*\s]+(?:[/?#][^\s]*)?$`) +var vaultFillPageURLPattern = regexp.MustCompile(`^https?://[^/?#@*\s]+(?:[/?#][^\s]*)?$`) // Reject duplicate and unknown keys without including payloads in diagnostics. func vaultParamsObject(raw, allowed string) (map[string]json.RawMessage, error) { - invalid := fmt.Errorf("--params must contain JSON objects with only supported, non-duplicate properties") + invalid := fmt.Errorf("operation parameters must contain JSON objects with only supported, non-duplicate properties") dec := json.NewDecoder(strings.NewReader(raw)) token, err := dec.Token() if err != nil || token != json.Delim('{') { @@ -45,7 +52,7 @@ func vaultParamsObject(raw, allowed string) (map[string]json.RawMessage, error) return nil, invalid } if key == "type" { - return nil, fmt.Errorf("--params must not contain type; the positional operation supplies it") + return nil, fmt.Errorf("operation parameters must not contain type; the positional operation supplies it") } if _, duplicate := object[key]; duplicate || !slices.Contains(keys, key) { return nil, invalid @@ -65,72 +72,134 @@ func vaultParamsObject(raw, allowed string) (map[string]json.RawMessage, error) return object, nil } -func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) (*vaultFillParams, error) { +func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) (*vaultOperationParams, error) { if strings.TrimSpace(operation) == "" { return nil, fmt.Errorf("operation must not be empty") } - if openSet && operation != "authorize" { - return nil, fmt.Errorf("--open is only supported for authorize") + if openSet && operation != "authorize" && operation != "collect" && operation != "prepare_checkout" { + return nil, fmt.Errorf("--open is only supported for authorize, collect, and prepare_checkout") + } + if len(raw) > 128*1024 { + return nil, fmt.Errorf("operation parameters exceed 128 KiB") + } + if operation == "prepare_checkout" { + if !paramsSet { + return nil, fmt.Errorf("prepare_checkout requires --params or --spec-file with checkout") + } + checkout, err := parseVaultCheckoutParams(raw) + if err != nil { + return nil, err + } + return &vaultOperationParams{Checkout: checkout}, nil } if operation != "fill" { if paramsSet { - return nil, fmt.Errorf("--params is only supported for fill; authorize takes no parameters") + return nil, fmt.Errorf("--params is only supported for fill and prepare_checkout; authorize takes no parameters") } return nil, nil } if !paramsSet { - return nil, fmt.Errorf("fill requires --params with browser_id, page_url, and fields") + return nil, fmt.Errorf("fill requires --params or --spec-file with browser_id and fields") } + fill, err := parseVaultFillParams(raw) + if err != nil { + return nil, err + } + return &vaultOperationParams{Fill: fill}, nil +} + +func parseVaultFillParams(raw string) (*vaultFillParams, error) { object, err := vaultParamsObject(raw, "browser_id page_url fields timeout_ms") if err != nil { return nil, err } var params vaultFillParams if json.Unmarshal(object["browser_id"], ¶ms.BrowserID) != nil || strings.TrimSpace(params.BrowserID) == "" { - return nil, fmt.Errorf("--params.browser_id must be a non-empty browser session ID, not a name") - } - if json.Unmarshal(object["page_url"], ¶ms.PageURL) != nil || !vaultFillPageURLPattern.MatchString(params.PageURL) { - return nil, fmt.Errorf("--params.page_url must be an exact HTTPS URL without credentials or a wildcard host") + return nil, fmt.Errorf("browser_id must be a non-empty browser session ID, not a name") } - u, err := url.Parse(params.PageURL) - if err != nil || u.Hostname() == "" || u.User != nil || u.Opaque != "" { - return nil, fmt.Errorf("--params.page_url must be an exact HTTPS URL without credentials") + if rawURL, present := object["page_url"]; present { + if json.Unmarshal(rawURL, ¶ms.PageURL) != nil || !vaultFillPageURLPattern.MatchString(params.PageURL) { + return nil, fmt.Errorf("page_url must be an exact HTTP or HTTPS URL without credentials or a wildcard host") + } + u, err := url.Parse(params.PageURL) + if err != nil || u.Hostname() == "" || u.User != nil || u.Opaque != "" { + return nil, fmt.Errorf("page_url must be an exact HTTP or HTTPS URL without credentials") + } } if timeout, ok := object["timeout_ms"]; ok { if json.Unmarshal(timeout, ¶ms.TimeoutMS) != nil || params.TimeoutMS == nil || *params.TimeoutMS < 1 || *params.TimeoutMS > 30000 { - return nil, fmt.Errorf("--params.timeout_ms must be an integer between 1 and 30000") + return nil, fmt.Errorf("timeout_ms must be an integer between 1 and 30000") } } var fields []json.RawMessage if json.Unmarshal(object["fields"], &fields) != nil || len(fields) < 1 || len(fields) > 32 { - return nil, fmt.Errorf("--params.fields must be an array of 1-32 field bindings") + return nil, fmt.Errorf("fields must be an array of 1-32 field bindings") } params.Fields = make([]vaultFillField, 0, len(fields)) for i, rawField := range fields { field, err := vaultParamsObject(string(rawField), "field selector format") if err != nil { - return nil, fmt.Errorf("--params.fields[%d]: %w", i, err) + return nil, fmt.Errorf("fields[%d]: %w", i, err) } var binding vaultFillField if json.Unmarshal(field["selector"], &binding.Selector) != nil || strings.TrimSpace(binding.Selector) == "" { - return nil, fmt.Errorf("--params.fields[%d].selector must be a non-empty CSS selector", i) + return nil, fmt.Errorf("fields[%d].selector must be a non-empty CSS selector", i) } - if json.Unmarshal(field["field"], &binding.Field) != nil { - return nil, fmt.Errorf("--params.fields[%d].field must be a supported card field", i) + if json.Unmarshal(field["field"], &binding.Field) != nil || strings.TrimSpace(binding.Field) == "" || len(binding.Field) > 64 { + return nil, fmt.Errorf("fields[%d].field must be a non-empty field name of at most 64 bytes", i) } - switch binding.Field { - case "expiration": - if json.Unmarshal(field["format"], &binding.Format) != nil || (binding.Format != "MM/YY" && binding.Format != "MM/YYYY") { - return nil, fmt.Errorf("--params.fields[%d].format must be MM/YY or MM/YYYY for expiration", i) - } - case "number", "cvc", "exp_month", "exp_year", "billing_name", "billing_line1", "billing_line2", "billing_city", "billing_state", "billing_postal_code", "billing_country": - if _, ok := field["format"]; ok { - return nil, fmt.Errorf("--params.fields[%d].format is only supported for expiration", i) + if format, present := field["format"]; present { + if json.Unmarshal(format, &binding.Format) != nil || (binding.Format != "MM/YY" && binding.Format != "MM/YYYY") { + return nil, fmt.Errorf("fields[%d].format must be MM/YY or MM/YYYY", i) } - default: - return nil, fmt.Errorf("--params.fields[%d].field must be a supported card field", i) } params.Fields = append(params.Fields, binding) } return ¶ms, nil } + +func validateVaultFillItem(params *vaultFillParams, item *kernel.VaultItemUnion) error { + switch item.Type { + case "credential": + var definition struct { + Spec struct { + Fields map[string]json.RawMessage `json:"fields"` + } `json:"spec"` + } + if json.Unmarshal([]byte(item.RawJSON()), &definition) != nil || len(definition.Spec.Fields) == 0 { + return fmt.Errorf("credential field definitions unavailable; fill was not invoked") + } + for i, field := range params.Fields { + if len(field.Selector) > 2048 { + return fmt.Errorf("fill binding %d: credential selectors must not exceed 2048 bytes", i) + } + if _, exists := definition.Spec.Fields[field.Field]; !exists { + return fmt.Errorf("fill binding %d must reference a declared credential field", i) + } + if field.Format != "" { + return fmt.Errorf("fill binding %d: format is not supported for credentials", i) + } + } + case "card": + if !strings.HasPrefix(params.PageURL, "https://") { + return fmt.Errorf("card fill requires an exact HTTPS page_url") + } + for i, field := range params.Fields { + switch field.Field { + case "expiration": + if field.Format != "MM/YY" && field.Format != "MM/YYYY" { + return fmt.Errorf("fill binding %d: expiration requires format MM/YY or MM/YYYY", i) + } + case "number", "cvc", "exp_month", "exp_year", "billing_name", "billing_line1", "billing_line2", "billing_city", "billing_state", "billing_postal_code", "billing_country": + if field.Format != "" { + return fmt.Errorf("fill binding %d: format is only supported for expiration", i) + } + default: + return fmt.Errorf("fill binding %d must reference a supported card field", i) + } + } + default: + return fmt.Errorf("fill is not supported for this item type") + } + return nil +} diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 2cb23636..2cd8ade1 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -32,15 +32,16 @@ var vaultMethodFields = vaultOutputFields{ "capabilities": {"single_use_card": vaultFieldsOf("eligible reasons")}, } var vaultItemFields = vaultOutputFields{ - "id": nil, "key": nil, "type": nil, "created_at": nil, "updated_at": nil, "expires_at": nil, + "id": nil, "key": nil, "type": nil, "version": nil, "created_at": nil, "updated_at": nil, "expires_at": nil, "available_operations": vaultOperationFields, "available_expansions": vaultOperationFields, - "action": vaultFieldsOf("name url"), + "action": vaultFieldsOf("name url expires_at"), "expanded": {"payment_methods": vaultMethodFields}, "spec": { "provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil, "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, - "context": nil, "expires_at": nil, + "context": nil, "expires_at": nil, "description": nil, + "fields": {"*": vaultFieldsOf("type required sensitive")}, "provider_config": vaultFieldsOf("id name"), "authorization": {"method": nil, "client": {"type": nil, "provider_config": vaultFieldsOf("id name")}}, "totals": vaultTotalFields, @@ -51,14 +52,16 @@ var vaultItemFields = vaultOutputFields{ }, "state": { "provider": nil, "status": nil, "status_reason": nil, "user_id": nil, "domains": nil, + "fields": {"*": vaultFieldsOf("has_value")}, "masks": vaultFieldsOf("brand last4"), "aliases": vaultFieldsOf("number cvc exp_month exp_year"), + "preparation": vaultFieldsOf("id status browser_id merchant_origin environment created_at expires_at approval_url"), "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 operation status authorization_id vault_session_id request_kind outcome_reason provider_status provider_code provider_request_id provider_payment_status provider_error_type provider_error_code provider_decline_code provider_error_param provider_http_status provider_response_bytes provider_latency_ms payment_intent_id payment_method_id checkout_session_id replay_attempted replay_delivered charged_amount_cents charged_currency charged_kind expected_cents actual_cents currency actual_currency intent_status amount_verified psp_error_code"), + "data": vaultFieldsOf("reason operation status authorization_id preparation_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 @@ -97,8 +100,18 @@ func filterVaultJSON(raw json.RawMessage, fields vaultOutputFields) (json.RawMes } result := make(vaultJSON) for key, children := range fields { + if key == "*" { + for name, value := range object { + filtered, err := filterVaultJSON(value, children) + if err != nil { + return nil, err + } + result[name] = filtered + } + continue + } if value, ok := object[key]; ok { - if key == "url" || key == "approval_url" || key == "merchant_url" || key == "image_url" || key == "product_url" { + if key == "url" || key == "approval_url" || key == "merchant_url" || key == "merchant_origin" || key == "image_url" || key == "product_url" { var address string if json.Unmarshal(value, &address) != nil || !vaultDisplayURL(address) { continue @@ -196,7 +209,7 @@ func printVaultOperationHints(item *kernel.VaultItemUnion, vault, key, project s } for _, op := range actions.Operations { command := prefix - if op.Type == "fill" { + if op.Type == "fill" || op.Type == "prepare_checkout" { command += " --params ''" } pterm.Printf("Invoke: %s -- %s %s %s\n", command, vaultShellArgument(vault), vaultShellArgument(key), vaultShellArgument(op.Type)) @@ -225,6 +238,10 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { {"Property", "Value"}, {"Key (immutable)", item.Key}, {"ID", item.ID}, {"Type", item.Type}, {"Provider", item.Spec.Provider}, {"Status", item.State.Status}, } + if item.Type == "credential" { + rows = append(rows, []string{"Version", fmt.Sprint(item.Version)}) + pterm.Info.Println("Use -o json for field definitions and presence; stored values are omitted") + } if item.Type == "wallet" { configID, configName := item.Spec.ProviderConfig.ID, item.Spec.ProviderConfig.Name if item.Spec.Provider == "link" { @@ -264,6 +281,15 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { 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.Preparation.Valid() { + p := item.State.Preparation + rows = append(rows, []string{"Preparation ID", p.ID}, []string{"Preparation status", string(p.Status)}, + []string{"Preparation browser", p.BrowserID}, []string{"Merchant origin", p.MerchantOrigin}, + []string{"Environment", string(p.Environment)}) + if !p.ExpiresAt.IsZero() { + rows = append(rows, []string{"Submit before", util.FormatLocal(p.ExpiresAt)}) + } + } if item.State.JSON.Authorization.Valid() { a := item.State.Authorization rows = append(rows, []string{"Checkout authorization", a.ID}, []string{"Authorization status", string(a.Status)}) @@ -299,7 +325,24 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction for _, op := range actions.Operations { pterm.Printf("Available operation: %s — %s\n", op.Type, op.Description) } + if item.Type == "credential" { + if actions.RequiredAction != "" { + pterm.Info.Println("Share the collection URL with the user to complete the credential form. Observe readiness with items get --wait 60; for edits to an already-ready item, compare versions without --wait.") + } + pterm.Info.Println("Do not use credential items for credit card data. Use wallet and card item types for credit cards and payment checkout instead.") + pterm.Info.Println("Ready means required fields are populated, not that login succeeded. Fill only when advertised; fill does not submit the form.") + return + } if item.Type == "card" { + if item.State.JSON.Preparation.Valid() { + switch item.State.Status { + case "preparing": + pterm.Info.Println("Keep the approval page open and poll items get --wait 60 until ready_to_submit before submitting native Pay.") + case "ready_to_submit": + pterm.Info.Println("Submit native Pay before preparation.expires_at; polling does not extend the deadline.") + } + pterm.Info.Println("Preparations are single-use, including after failure or expiry. Preparation consumed means claimed, not payment success. Do not retry automatically.") + } card := item.AsCard() for _, expansion := range card.AvailableExpansions { pterm.Printf("Available expansion: %s — %s\n", expansion.Type, expansion.Description) diff --git a/cmd/vaults_policy.go b/cmd/vaults_policy.go index d235c12a..38b91f25 100644 --- a/cmd/vaults_policy.go +++ b/cmd/vaults_policy.go @@ -32,10 +32,14 @@ func effectiveVaultItemActions(item *kernel.VaultItemUnion) (vaultItemActions, e if err := json.Unmarshal([]byte(item.RawJSON()), &fields); err != nil { return vaultItemActions{}, fmt.Errorf("invalid vault item operations: %w", err) } + approvalURL := item.State.Authorization.ApprovalURL + if item.State.Status == "preparing" && item.State.Preparation.ApprovalURL != "" { + approvalURL = item.State.Preparation.ApprovalURL + } return vaultItemActions{ RequiredAction: item.Action.Name, ActionURL: item.Action.URL, - ApprovalURL: item.State.Authorization.ApprovalURL, + ApprovalURL: approvalURL, Operations: fields.Operations, }, nil } diff --git a/cmd/vaults_prepare_checkout.go b/cmd/vaults_prepare_checkout.go new file mode 100644 index 00000000..d0e0c87c --- /dev/null +++ b/cmd/vaults_prepare_checkout.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + + kernel "github.com/kernel/kernel-go-sdk" +) + +func parseVaultCheckoutParams(raw string) (*kernel.VaultCheckoutContextParam, error) { + object, err := vaultParamsObject(raw, "checkout") + if err != nil { + return nil, err + } + checkout, err := vaultParamsObject(string(object["checkout"]), "browser_id merchant_origin environment") + if err != nil { + return nil, fmt.Errorf("invalid checkout: %w", err) + } + var params kernel.VaultCheckoutContextParam + if json.Unmarshal(checkout["browser_id"], ¶ms.BrowserID) != nil || strings.TrimSpace(params.BrowserID) == "" { + return nil, fmt.Errorf("checkout.browser_id must be a non-empty browser session ID") + } + if json.Unmarshal(checkout["environment"], ¶ms.Environment) != nil || (params.Environment != "production" && params.Environment != "sandbox") { + return nil, fmt.Errorf("checkout.environment must be production or sandbox") + } + if json.Unmarshal(checkout["merchant_origin"], ¶ms.MerchantOrigin) != nil { + return nil, fmt.Errorf("checkout.merchant_origin must be a canonical HTTPS origin (HTTP localhost is allowed for tests)") + } + u, err := url.Parse(params.MerchantOrigin) + if err != nil || u.Hostname() == "" || u.User != nil || u.Opaque != "" || + u.Path != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || + params.MerchantOrigin != u.Scheme+"://"+u.Host || strings.ContainsAny(u.Host, "*\\") || + (u.Scheme != "https" && !(u.Scheme == "http" && u.Hostname() == "localhost")) { + return nil, fmt.Errorf("checkout.merchant_origin must be a canonical HTTPS origin (HTTP localhost is allowed for tests)") + } + return ¶ms, nil +} diff --git a/cmd/vaults_prepare_checkout_test.go b/cmd/vaults_prepare_checkout_test.go new file mode 100644 index 00000000..2130fd5c --- /dev/null +++ b/cmd/vaults_prepare_checkout_test.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const checkoutParamsFixture = `{"checkout":{"browser_id":"browser-1","merchant_origin":"https://shop.example","environment":"production"}}` +const preparationCardFixture = `{"id":"card-1","key":"order-1","type":"card","spec":{"provider":"agentcard","wallet":"wallet-1","merchant":"Shop","amount":1234,"currency":"usd"},"state":{"provider":"agentcard","status":"preparing","status_reason":"Awaiting approval","preparation":{"id":"prep-1","status":"awaiting_approval","browser_id":"browser-1","merchant_origin":"https://shop.example","environment":"production","created_at":"2026-09-15T00:00:00Z","expires_at":"2026-09-15T00:00:30Z","approval_url":"https://approve.example/prepare","secret":"never-print"}},"action":{"name":"spend_approval","url":"https://approve.example/prepare"},"available_operations":[{"type":"prepare_checkout","description":"Prepare after approval"}],"available_expansions":[]}` + +func TestVaultPrepareCheckout(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, file := range []bool{false, true} { + t.Run(fmt.Sprint(file), func(t *testing.T) { + posts := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "POST" { + posts++ + assert.Equal(t, "/vaults/user-123/items/order-1/operations", r.URL.Path) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"prepare_checkout","checkout":{"browser_id":"browser-1","merchant_origin":"https://shop.example","environment":"production"}}`, string(body)) + } + fmt.Fprint(w, preparationCardFixture) + }) + args := []string{"vaults", "items", "invoke", "user-123", "order-1", "prepare_checkout", "-o", "json"} + if file { + args = append(args, "--spec-file", credentialSpecFile(t, checkoutParamsFixture)) + } else { + args = append(args, "--params", checkoutParamsFixture) + } + out, _, err := executeVaultCommand(t, client, args...) + require.NoError(t, err) + assert.Equal(t, 1, posts) + assert.True(t, json.Valid([]byte(out))) + for _, value := range []string{"prep-1", "browser-1", "https://shop.example", "production", "2026-09-15T00:00:30Z", "https://approve.example/prepare", "Awaiting approval"} { + assert.Contains(t, out, value) + } + assert.NotContains(t, out, "never-print") + }) + } +} + +func TestVaultPrepareCheckoutInvalidParams(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, raw := range []string{ + `{}`, `null`, `{"checkout":null}`, `{"checkout":{}}`, + `{"type":"prepare_checkout","checkout":{}}`, `{"checkout":{},"checkout":{}}`, + strings.Replace(checkoutParamsFixture, `"browser-1"`, `null`, 1), + strings.Replace(checkoutParamsFixture, `"browser-1"`, `""`, 1), + strings.Replace(checkoutParamsFixture, `"production"`, `"invalid"`, 1), + strings.Replace(checkoutParamsFixture, `"environment":`, `"extra":"never-print","environment":`, 1), + strings.Replace(checkoutParamsFixture, `"browser_id":`, `"browser_id":"duplicate","browser_id":`, 1), + strings.Repeat("x", 128*1024+1), + } { + t.Run(fmt.Sprint(len(raw))+raw[:min(len(raw), 45)], func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { calls++ }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "user-123", "order-1", "prepare_checkout", "--params", raw) + require.Error(t, err) + assert.NotContains(t, err.Error(), "never-print") + assert.Zero(t, calls) + }) + } + for _, origin := range []string{"http://shop.example", "https://user:pass@shop.example", "https://shop.example/checkout", "https://shop.example/", "https://shop.example?x=1", "https://shop.example#", "https://shop.example?", "https://*.example"} { + _, err := parseVaultCheckoutParams(strings.Replace(checkoutParamsFixture, "https://shop.example", origin, 1)) + require.Error(t, err, origin) + } + _, err := parseVaultCheckoutParams(strings.Replace(checkoutParamsFixture, "https://shop.example", "http://localhost:3000", 1)) + require.NoError(t, err) + _, err = parseVaultOperationParams("prepare_checkout", "", false, false) + require.Error(t, err) +} + +func TestVaultPrepareCheckoutChecksAvailabilityAndNeverRetries(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, status := range []int{0, 409, 429, 500} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + posts := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "GET" { + fmt.Fprint(w, preparationCardFixture) + return + } + posts++ + if status == 0 { + conn, _, err := w.(http.Hijacker).Hijack() + require.NoError(t, err) + conn.Close() + return + } + w.WriteHeader(status) + fmt.Fprint(w, `{"message":"operation failed"}`) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "user-123", "order-1", "prepare_checkout", "--params", checkoutParamsFixture) + require.Error(t, err) + assert.Equal(t, 1, posts) + }) + } + for _, fixture := range []string{ + strings.Replace(preparationCardFixture, `"type":"prepare_checkout"`, `"type":"other"`, 1), + strings.Replace(preparationCardFixture, `"status":"preparing"`, `"status":"recovery_required"`, 1), + strings.ReplaceAll(preparationCardFixture, `"agentcard"`, `"link"`), + } { + posts := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + posts++ + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, fixture) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "user-123", "order-1", "prepare_checkout", "--params", checkoutParamsFixture) + require.Error(t, err) + assert.Zero(t, posts) + } +} + +func TestVaultPreparationOpenAndGuidance(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, preparationCardFixture) + }) + opened := "" + c := VaultsCmd{vaults: &client.Vaults, openURL: func(url string) error { opened = url; return nil }} + params, err := parseVaultOperationParams("prepare_checkout", checkoutParamsFixture, true, true) + require.NoError(t, err) + captureStdout(t, func() { + require.NoError(t, c.Invoke(context.Background(), "user-123", "order-1", "prepare_checkout", params, "json", true)) + }) + assert.Equal(t, "https://approve.example/prepare", opened) + for _, status := range []string{"preparing", "ready_to_submit", "consumed"} { + fixture := strings.Replace(preparationCardFixture, `"status":"preparing"`, `"status":"`+status+`"`, 1) + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, fixture) + }) + _, text, err := executeVaultCommand(t, client, "vaults", "items", "get", "user-123", "order-1") + require.NoError(t, err) + assert.Contains(t, text, "Preparation ID") + assert.Contains(t, text, "single-use") + assert.NotContains(t, text, "never-print") + if status == "ready_to_submit" { + assert.Contains(t, text, "polling does not extend") + } + } +} diff --git a/cmd/vaults_sdk_contract_test.go b/cmd/vaults_sdk_contract_test.go new file mode 100644 index 00000000..29fb41eb --- /dev/null +++ b/cmd/vaults_sdk_contract_test.go @@ -0,0 +1,125 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCredentialUpdateIdentityPrecondition(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, tc := range []struct { + name, expectedID string + setID bool + status int + }{ + {name: "omitted", status: 200}, + {name: "bound", expectedID: "credential-original", setID: true, status: 200}, + {name: "replacement conflict", expectedID: "credential-original", setID: true, status: 409}, + {name: "empty", setID: true, status: 200}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, "PATCH", r.Method) + var body map[string]json.RawMessage + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.JSONEq(t, `7`, string(body["version"])) + if tc.setID { + assert.JSONEq(t, `"credential-original"`, string(body["expected_item_id"])) + } else { + assert.NotContains(t, body, "expected_item_id") + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.status) + if tc.status == 200 { + fmt.Fprint(w, credentialFixture) + } else { + fmt.Fprint(w, `{"message":"conflict"}`) + } + }) + args := []string{"vaults", "credentials", "update", "user-123", "login", "--version", "7", "--spec-file", credentialSpecFile(t, `{"fields":{"password":{"value":""}}}`), "-o", "json"} + if tc.setID { + args = append(args, "--expected-item-id", tc.expectedID) + } + _, _, err := executeVaultCommand(t, client, args...) + if tc.name == "empty" { + require.Error(t, err) + assert.Zero(t, calls) + } else { + assert.Equal(t, 1, calls) + if tc.status == 409 { + require.Error(t, err) + } else { + require.NoError(t, err) + } + } + }) + } +} + +func TestCredentialInitialValuesWithReleasedSDK(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + spec := `{"description":"Example","fields":{"username":{"type":"text","sensitive":false,"value":"synthetic-user"},"email":{"type":"email","sensitive":false,"value":"test@example.com"},"password":{"type":"password","sensitive":true,"value":"synthetic-password"},"otp":{"type":"totp","sensitive":true,"value":"JBSWY3DPEHPK3PXP"}}}` + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, "PUT", r.Method) + var body map[string]json.RawMessage + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.JSONEq(t, spec, string(body["spec"])) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id":"credential-1","key":"login","type":"credential","version":1,"spec":%s,"state":{"status":"ready","fields":{"otp":{"has_value":true,"value":"JBSWY3DPEHPK3PXP"}}},"available_operations":[],"available_expansions":[]}`, spec) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "user-123", "login", "--spec-file", credentialSpecFile(t, spec), "-o", "json") + require.NoError(t, err) + assert.Equal(t, 1, calls) + for _, value := range []string{"synthetic-user", "test@example.com", "synthetic-password", "JBSWY3DPEHPK3PXP"} { + assert.NotContains(t, out, value) + } + assert.Contains(t, out, `"has_value": true`) +} + +func TestVaultPreparationApprovalURLIsPrintedInFull(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + approvalURL := "https://approve.example/" + strings.Repeat("long-token", 40) + fixture := strings.Replace(preparationCardFixture, `"action":{"name":"spend_approval","url":"https://approve.example/prepare"},`, "", 1) + fixture = strings.Replace(fixture, "https://approve.example/prepare", approvalURL, 1) + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, fixture) + }) + _, text, err := executeVaultCommand(t, client, "vaults", "items", "get", "user-123", "order-1") + require.NoError(t, err) + assert.Contains(t, text, "Approval URL:\n"+approvalURL+"\n") + assert.NotContains(t, text, "Preparation approval URL") +} + +func TestVaultFileValidationUsesFieldNames(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("invalid input must not reach the API") + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "user-123", "login", "fill", "--spec-file", credentialSpecFile(t, `{"browser_id":"","fields":[]}`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "browser_id") + assert.NotContains(t, err.Error(), "--params") +} + +func TestVaultPreparationEventsAreProjected(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `[{"id":"event-1","name":"preparation_ready","created_at":"2026-09-15T00:00:00Z","browser_id":"browser-1","data":{"preparation_id":"prep-1","reason":"ready","provider_secret":"never-print"}}]`) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "events", "user-123", "order-1", "-o", "json") + require.NoError(t, err) + assert.Contains(t, out, `"preparation_id": "prep-1"`) + assert.NotContains(t, out, "never-print") +} diff --git a/cmd/vaults_secrets.go b/cmd/vaults_secrets.go index 24803447..3616ad0f 100644 --- a/cmd/vaults_secrets.go +++ b/cmd/vaults_secrets.go @@ -21,11 +21,11 @@ func vaultCredentialError(err error) error { case 400: return fmt.Errorf("vault request rejected (HTTP 400); check the input and credential validity") case 403: - return fmt.Errorf("vault request forbidden (HTTP 403); configuration writes require organization-scoped authentication") + return fmt.Errorf("vault request forbidden (HTTP 403); check authentication scope and permissions") case 404: return fmt.Errorf("vault resource not found (HTTP 404)") case 409: - return fmt.Errorf("vault conflict (HTTP 409); names and bindings must match, grants cannot be replaced, and referenced configurations cannot be deleted") + return fmt.Errorf("vault conflict (HTTP 409); inspect current version, state, and immutable bindings before retrying") default: return fmt.Errorf("vault request failed (HTTP %d); outcome may be unresolved, inspect existing state before taking further action", apiErr.StatusCode) } diff --git a/cmd/vaults_spec_test.go b/cmd/vaults_spec_test.go index b20f2078..b5b31c99 100644 --- a/cmd/vaults_spec_test.go +++ b/cmd/vaults_spec_test.go @@ -36,7 +36,7 @@ func TestVaultRawSpecForwarding(t *testing.T) { 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) + assert.Equal(t, "card", body.Type) } else { assert.Equal(t, http.MethodPut, r.Method) assert.Equal(t, strings.TrimSuffix(strings.Fields(path)[0], "s"), body.Type) diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go index 8584a189..14942156 100644 --- a/cmd/vaults_test.go +++ b/cmd/vaults_test.go @@ -278,12 +278,8 @@ func TestVaultCardRequestMapping(t *testing.T) { 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) - } + assert.JSONEq(t, `"card"`, string(body["type"])) + assert.Len(t, body, 2) if provider == "link" { assert.JSONEq(t, fmt.Sprintf(`{"provider":"link","wallet":"wallet-1","amount":1234,"currency":"USD","merchant_name":"Example Shop","merchant_url":"https://shop.example","payment_method_id":"pm-1","context":%q}`, strings.Repeat("Purchase purpose. ", 7)), string(body["spec"])) } else { diff --git a/cmd/vaults_wallet_config_test.go b/cmd/vaults_wallet_config_test.go index cd0f1f63..540ef44c 100644 --- a/cmd/vaults_wallet_config_test.go +++ b/cmd/vaults_wallet_config_test.go @@ -264,7 +264,7 @@ func TestVaultPendingUpdatePreservesOmissionsAndEmptyLists(t *testing.T) { client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodPatch, r.Method) body, _ := io.ReadAll(r.Body) - assert.JSONEq(t, `{"spec":{"provider":"link","wallet":"wallet-1","amount":2000`+fields+`}}`, string(body)) + assert.JSONEq(t, `{"type":"card","spec":{"provider":"link","wallet":"wallet-1","amount":2000`+fields+`}}`, string(body)) w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, strings.ReplaceAll(requestedCardFixture, "requested", "recovery_required")) }) diff --git a/go.mod b/go.mod index fa46bff5..82826e7f 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.102.0 + github.com/kernel/kernel-go-sdk v0.105.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 6f4ca2b0..24f396d5 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.102.0 h1:ZGumOc/Bub48B8zRye44BSLNCgqM/Z6K7XcKX0DCjH0= -github.com/kernel/kernel-go-sdk v0.102.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.105.0 h1:xKCFrduHuKTD6ceytLkMHUDRafnZaJcc8WUPSXGGILw= +github.com/kernel/kernel-go-sdk v0.105.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= diff --git a/scripts/build-preview.sh b/scripts/build-preview.sh new file mode 100644 index 00000000..14dd04d6 --- /dev/null +++ b/scripts/build-preview.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." +sha=$(git rev-parse HEAD) +version="0.0.0-preview.g${sha:0:12}" +date=$(date -u +%Y-%m-%dT%H:%M:%SZ) +mkdir -p dist/preview +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +for os in linux darwin windows; do + for arch in amd64 arm64; do + binary=kernel + if [ "$os" = windows ]; then binary=kernel.exe; fi + CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath \ + -ldflags "-s -w -X main.version=$version -X main.commit=$sha -X main.date=$date" \ + -o "$work/$binary" ./cmd/kernel + tar -czf "dist/preview/kernel_${version}_${os}_${arch}.tar.gz" -C "$work" "$binary" + done +done +(cd dist/preview && sha256sum kernel_*.tar.gz > SHA256SUMS) +echo "Preview $version ($sha) built in dist/preview"