Skip to content

Commit 6758ec9

Browse files
committed
Cover released vault checkout and credential preconditions
1 parent 889ca87 commit 6758ec9

12 files changed

Lines changed: 420 additions & 32 deletions

‎README.md‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ Commands with JSON output support:
128128
- **Proxies**: `create`, `list`, `get`, `update`, `check`
129129
- **API Keys**: `create`, `list`, `get`, `update`, `rotate`
130130
- **Auth Connections**: `timeline`
131-
- **Vaults**: `create`, `list`, `get`, `credentials create/update`, `items list/get/events/invoke` (including `collect` and `fill`), `wallets create/payment-methods`, `cards create/update` (display-safe public fields only)
131+
- **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)
132132
- **Projects**: `update`
133133
- **Org**: `limits get/set`
134134
- **Apps**: `list`, `history`
@@ -300,7 +300,9 @@ with a spec such as `{"fields":{"password":{"value":"replacement"}}}`. Keep actu
300300
secrets in protected files or stdin, never shell arguments. Omission preserves values;
301301
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,
302302
without retries. `items invoke <vault> <key> collect` reopens the full form without
303-
clearing values; compare versions to observe edits to already-ready items.
303+
clearing values; compare versions to observe edits to already-ready items. When an update
304+
is bound to an earlier read, also pass `--expected-item-id <id>` to reject a replacement
305+
item at the same key. Neither precondition is refreshed automatically.
304306

305307
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.
306308

@@ -515,7 +517,7 @@ card spec. Otherwise, the cardholder selects a card at approval. A reusable card
515517
#### Invoking item operations
516518

517519
`items get` displays every `available_operations` entry's type and description, plus
518-
an `items invoke` command retaining the selected project (replace `<json>` for fill).
520+
an `items invoke` command retaining the selected project (replace `<json>` for fill or prepare_checkout).
519521
Read the description and follow its approval requirements before invoking. Required user actions
520522
(OAuth, enrollment, MFA, spend approval) appear separately; they are not operations to invoke
521523
through this endpoint.
@@ -527,14 +529,37 @@ actions in `recovery_required`, even if a stale action or operation was returned
527529

528530
`authorize` sends `{"type":"authorize"}` without `--params` and returns the updated item,
529531
possibly with a required user action. `collect` is also parameterless and returns a credential
530-
collection URL. `--open` is supported for authorize and collect.
531-
The [API spec](https://api.onkernel.com/spec.yaml) also accepts `fill`, with its inputs in
532+
collection URL. `--open` is supported for authorize, collect, and prepare_checkout.
533+
The [API spec](https://api.onkernel.com/spec.yaml) also accepts `fill` and `prepare_checkout`, with their inputs in
532534
`--params` or `--spec-file <path|->` (mutually exclusive, maximum 128 KiB). The positional
533535
operation supplies `type`; including `type` in either input is rejected.
534536
Parameters must be a JSON object without unknown or duplicate properties. There is no
535537
operation `--spec` flag; wallet/card `--spec` flags remain unchanged. New parameterless
536538
operations can still be invoked by name when advertised.
537539

540+
##### Prepare an AgentCard checkout
541+
542+
For an unused AgentCard card, invoke `prepare_checkout` only when advertised:
543+
544+
```bash
545+
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
546+
kernel vaults items get user-123 order-1 --wait 60 -o json
547+
```
548+
549+
`--spec-file <path|->` accepts the same JSON. `browser_id` is the active session with
550+
this vault attached. `merchant_origin` is the canonical HTTPS origin of the top-level
551+
merchant document, not the Square iframe; HTTP localhost is allowed for tests.
552+
`environment` is `production` or `sandbox` and refers to Square, not the credential mode.
553+
554+
Keep the approval page open. Poll until the item's status is `ready_to_submit`, then
555+
submit native Pay before `state.preparation.expires_at`. Readiness lasts at most 30
556+
seconds, and polling does not extend it. The CLI displays the preparation ID, status,
557+
browser, origin, environment, approval URL, and submission deadline.
558+
559+
Each preparation is single-use, including after failure or expiry. A preparation
560+
marked `consumed` has been claimed; it does not prove the payment settled or succeeded.
561+
Do not automatically retry or switch checkout paths after an uncertain result.
562+
538563
##### Fill browser fields
539564

540565
Fill supports credential items and ready Link cards when advertised by the API, not AgentCard.

‎cmd/vaults.go‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -202,13 +202,16 @@ func (c VaultsCmd) SaveCard(ctx context.Context, vault, key string, spec kernel.
202202
return c.showItem(item, output, false)
203203
}
204204

205-
func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, params *vaultFillParams, output string, open bool) error {
205+
func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, params *vaultOperationParams, output string, open bool) error {
206206
if strings.TrimSpace(operation) == "" {
207207
return fmt.Errorf("operation must not be empty")
208208
}
209-
if operation == "fill" && (params == nil || open) {
209+
if operation == "fill" && (params == nil || params.Fill == nil || open) {
210210
return fmt.Errorf("fill requires --params and does not support --open")
211211
}
212+
if operation == "prepare_checkout" && (params == nil || params.Checkout == nil) {
213+
return fmt.Errorf("prepare_checkout requires checkout parameters")
214+
}
212215
item, err := c.vaults.Items.Get(ctx, key, kernel.VaultItemGetParams{IDOrName: vault}, option.WithMaxRetries(0))
213216
if err != nil {
214217
if operation == "fill" {
@@ -240,13 +243,18 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par
240243
return fmt.Errorf("operation %q is not advertised in available_operations; inspect the item", operation)
241244
}
242245
if operation == "fill" {
243-
if err := validateVaultFillItem(params, item); err != nil {
246+
if err := validateVaultFillItem(params.Fill, item); err != nil {
244247
return err
245248
}
246-
return c.fill(ctx, vault, key, params, output)
249+
return c.fill(ctx, vault, key, params.Fill, output)
247250
}
248251
request := kernel.VaultItemPerformOperationParams{IDOrName: vault}
249-
if operation == "collect" {
252+
if operation == "prepare_checkout" {
253+
if item.Type != "card" || item.Spec.Provider != "agentcard" {
254+
return fmt.Errorf("prepare_checkout requires an AgentCard card")
255+
}
256+
request.OfPrepareCheckout = &kernel.PrepareCheckoutVaultItemOperationRequestParam{Type: "prepare_checkout", Checkout: *params.Checkout}
257+
} else if operation == "collect" {
250258
request.OfCollect = &kernel.CollectVaultItemOperationRequestParam{Type: "collect"}
251259
} else {
252260
// Preserve support for other advertised parameterless operations.

‎cmd/vaults_commands.go‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,12 @@ Fill is available for credential items and ready Link cards when advertised, not
167167
Fill never submits forms. completed means fields were filled, not website acceptance.
168168
failed may leave partial writes; unknown quarantines the browser. Never automatically
169169
retry or fall back to aliases. Requests are not automatically retried.
170-
Only collect/authorize may use --open. Fill returns value-free per-field outcomes;
170+
prepare_checkout requires checkout.browser_id, checkout.merchant_origin (canonical HTTPS
171+
origin of the top-level merchant page), and checkout.environment (production or sandbox).
172+
Use only when advertised for an AgentCard card. Keep the returned approval page open,
173+
poll until ready_to_submit, then submit native Pay before preparation.expires_at.
174+
Preparations are single-use, including after failure or expiry; never retry automatically.
175+
collect/authorize/prepare_checkout may use --open. Fill returns value-free per-field outcomes;
171176
completed exits 0, failed/unknown exit nonzero with valid JSON retained on stdout in -o json.`,
172177
Example: ` kernel vaults items invoke user-vault login collect
173178
kernel vaults items invoke user-vault login fill --spec-file - <<'JSON'
@@ -179,8 +184,8 @@ JSON
179184
raw, _ := cmd.Flags().GetString("params")
180185
paramsSet := cmd.Flags().Changed("params")
181186
if cmd.Flags().Changed("spec-file") {
182-
if args[2] != "fill" {
183-
return fmt.Errorf("--spec-file is only supported for fill")
187+
if args[2] != "fill" && args[2] != "prepare_checkout" {
188+
return fmt.Errorf("--spec-file is only supported for fill and prepare_checkout")
184189
}
185190
data, err := readVaultSpecFile(cmd)
186191
if err != nil {
@@ -194,8 +199,8 @@ JSON
194199
}
195200
return getVaultsHandler(cmd).Invoke(cmd.Context(), args[0], args[1], args[2], params, vaultOutput(cmd), open)
196201
}}
197-
invoke.Flags().String("params", "", "Fill parameters JSON (maximum 128 KiB); omit type and credential values")
198-
invoke.Flags().String("spec-file", "", "Fill parameters JSON file (use '-' for stdin; maximum 128 KiB)")
202+
invoke.Flags().String("params", "", "Fill or prepare_checkout parameters JSON (maximum 128 KiB); omit type and credential values")
203+
invoke.Flags().String("spec-file", "", "Fill or prepare_checkout parameters JSON file (use '-' for stdin; maximum 128 KiB)")
199204
invoke.MarkFlagsMutuallyExclusive("params", "spec-file")
200205
invoke.Flags().Bool("open", false, "Open a returned HTTPS action URL in your browser")
201206
addVaultJSONOutputFlag(invoke)

‎cmd/vaults_credentials.go‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"io"
88
"os"
9+
"strings"
910

1011
kernel "github.com/kernel/kernel-go-sdk"
1112
"github.com/kernel/kernel-go-sdk/option"
@@ -49,13 +50,18 @@ func newVaultCredentialsCommand() *cobra.Command {
4950
}
5051
version, _ := cmd.Flags().GetInt64("version")
5152
open, _ := cmd.Flags().GetBool("open")
52-
return getVaultsHandler(cmd).saveCredential(cmd.Context(), args[0], args[1], data, update, version, vaultOutput(cmd), open)
53+
expectedID, _ := cmd.Flags().GetString("expected-item-id")
54+
if cmd.Flags().Changed("expected-item-id") && strings.TrimSpace(expectedID) == "" {
55+
return fmt.Errorf("--expected-item-id must not be empty")
56+
}
57+
return getVaultsHandler(cmd).saveCredential(cmd.Context(), args[0], args[1], data, update, version, expectedID, vaultOutput(cmd), open)
5358
},
5459
}
5560
if update {
5661
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."
5762
cmd.Flags().Int64("version", 0, "Expected version from items get (required; never auto-refreshed)")
5863
_ = cmd.MarkFlagRequired("version")
64+
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")
5965
cmd.Example = " kernel vaults credentials update user-vault login --version 2 --spec-file changes.json"
6066
} else {
6167
cmd.Example = ` kernel vaults credentials create user-vault login --spec-file - <<'JSON'
@@ -97,7 +103,7 @@ func readVaultSpecFile(cmd *cobra.Command) ([]byte, error) {
97103
return data, nil
98104
}
99105

100-
func (c VaultsCmd) saveCredential(ctx context.Context, vault, key string, data []byte, update bool, version int64, output string, open bool) error {
106+
func (c VaultsCmd) saveCredential(ctx context.Context, vault, key string, data []byte, update bool, version int64, expectedID, output string, open bool) error {
101107
var item *kernel.VaultItemUnion
102108
var err error
103109
if update {
@@ -108,7 +114,11 @@ func (c VaultsCmd) saveCredential(ctx context.Context, vault, key string, data [
108114
if json.Unmarshal(data, &spec) != nil {
109115
return fmt.Errorf("invalid credential update spec")
110116
}
111-
item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCredentialVaultItemUpdateRequest: &kernel.CredentialVaultItemUpdateRequestParam{Type: "credential", Version: version, Spec: spec}}, option.WithMaxRetries(0))
117+
request := kernel.CredentialVaultItemUpdateRequestParam{Type: "credential", Version: version, Spec: spec}
118+
if expectedID != "" {
119+
request.ExpectedItemID = kernel.String(expectedID)
120+
}
121+
item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCredentialVaultItemUpdateRequest: &request}, option.WithMaxRetries(0))
112122
} else {
113123
var spec kernel.CredentialVaultItemSpecInputParam
114124
if json.Unmarshal(data, &spec) != nil || len(spec.Fields) == 0 {

‎cmd/vaults_credentials_test.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ func TestCredentialCreateAndUpdate(t *testing.T) {
3838
if update {
3939
assert.Equal(t, "PATCH", r.Method)
4040
assert.JSONEq(t, `2`, string(body["version"]))
41+
assert.JSONEq(t, `"credential-1"`, string(body["expected_item_id"]))
4142
assert.JSONEq(t, `{"fields":{"password":{"value":null}}}`, string(body["spec"]))
4243
} else {
4344
assert.Equal(t, "PUT", r.Method)
@@ -50,7 +51,7 @@ func TestCredentialCreateAndUpdate(t *testing.T) {
5051
if update {
5152
args[2] = "update"
5253
args[6] = credentialSpecFile(t, `{"fields":{"password":{"value":null}}}`)
53-
args = append(args, "--version", "2")
54+
args = append(args, "--version", "2", "--expected-item-id", "credential-1")
5455
}
5556
out, _, err := executeVaultCommand(t, client, args...)
5657
require.NoError(t, err)
@@ -74,7 +75,7 @@ func TestCredentialWriteErrorsAreRedactedAndNotRetried(t *testing.T) {
7475
io.WriteString(w, `{"message":"secret-echo"}`)
7576
})
7677
c := VaultsCmd{vaults: &client.Vaults}
77-
err := c.saveCredential(context.Background(), "user", "login", []byte(`{"fields":{"password":{"type":"password","value":"secret-echo"}}}`), false, 0, "json", false)
78+
err := c.saveCredential(context.Background(), "user", "login", []byte(`{"fields":{"password":{"type":"password","value":"secret-echo"}}}`), false, 0, "", "json", false)
7879
require.Error(t, err)
7980
assert.NotContains(t, err.Error(), "secret-echo")
8081
assert.Equal(t, 1, calls)

‎cmd/vaults_operation_params.go‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import (
1212
kernel "github.com/kernel/kernel-go-sdk"
1313
)
1414

15+
type vaultOperationParams struct {
16+
Fill *vaultFillParams
17+
Checkout *kernel.VaultCheckoutContextParam
18+
}
19+
1520
type vaultFillParams struct {
1621
BrowserID string `json:"browser_id"`
1722
PageURL string `json:"page_url,omitempty"`
@@ -67,25 +72,43 @@ func vaultParamsObject(raw, allowed string) (map[string]json.RawMessage, error)
6772
return object, nil
6873
}
6974

70-
func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) (*vaultFillParams, error) {
75+
func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) (*vaultOperationParams, error) {
7176
if strings.TrimSpace(operation) == "" {
7277
return nil, fmt.Errorf("operation must not be empty")
7378
}
74-
if openSet && operation != "authorize" && operation != "collect" {
75-
return nil, fmt.Errorf("--open is only supported for authorize and collect")
79+
if openSet && operation != "authorize" && operation != "collect" && operation != "prepare_checkout" {
80+
return nil, fmt.Errorf("--open is only supported for authorize, collect, and prepare_checkout")
81+
}
82+
if len(raw) > 128*1024 {
83+
return nil, fmt.Errorf("operation parameters exceed 128 KiB")
84+
}
85+
if operation == "prepare_checkout" {
86+
if !paramsSet {
87+
return nil, fmt.Errorf("prepare_checkout requires --params or --spec-file with checkout")
88+
}
89+
checkout, err := parseVaultCheckoutParams(raw)
90+
if err != nil {
91+
return nil, err
92+
}
93+
return &vaultOperationParams{Checkout: checkout}, nil
7694
}
7795
if operation != "fill" {
7896
if paramsSet {
79-
return nil, fmt.Errorf("--params is only supported for fill; authorize takes no parameters")
97+
return nil, fmt.Errorf("--params is only supported for fill and prepare_checkout; authorize takes no parameters")
8098
}
8199
return nil, nil
82100
}
83101
if !paramsSet {
84102
return nil, fmt.Errorf("fill requires --params or --spec-file with browser_id and fields")
85103
}
86-
if len(raw) > 128*1024 {
87-
return nil, fmt.Errorf("fill parameters exceed 128 KiB")
104+
fill, err := parseVaultFillParams(raw)
105+
if err != nil {
106+
return nil, err
88107
}
108+
return &vaultOperationParams{Fill: fill}, nil
109+
}
110+
111+
func parseVaultFillParams(raw string) (*vaultFillParams, error) {
89112
object, err := vaultParamsObject(raw, "browser_id page_url fields timeout_ms")
90113
if err != nil {
91114
return nil, err

0 commit comments

Comments
 (0)