Skip to content

Commit 173e2bd

Browse files
committed
Add project-scoped vault payment commands
1 parent a4b3d10 commit 173e2bd

12 files changed

Lines changed: 1806 additions & 14 deletions

‎.github/workflows/test.yaml‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,21 @@ jobs:
2121
uses: actions/setup-go@v6
2222
with:
2323
go-version-file: "go.mod"
24-
cache: true
24+
cache: false
25+
26+
- name: Create read-only token for the preview SDK
27+
id: sdk-token
28+
uses: actions/create-github-app-token@v3
29+
with:
30+
app-id: ${{ secrets.ADMIN_APP_ID }}
31+
private-key: ${{ secrets.ADMIN_APP_PRIVATE_KEY }}
32+
repositories: kernel-go-sdk-staging
33+
permission-contents: read
2534

2635
- name: Run tests
27-
run: make test
36+
env:
37+
GOPRIVATE: github.com/kernel/kernel-go-sdk-staging
38+
GH_TOKEN: ${{ steps.sdk-token.outputs.token }}
39+
run: |
40+
gh auth setup-git
41+
make test

‎README.md‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +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`, `items list/get/events`, `wallets create/payment-methods`, `cards create/update/authorize` (display-safe public fields only)
131132
- **Projects**: `update`
132133
- **Org**: `limits get/set`
133134
- **Apps**: `list`, `history`
@@ -223,6 +224,7 @@ Commands with JSON output support:
223224
- `--proxy-mode direct|default` - Egress mode instead of a selected proxy: `direct` for no proxy regardless of stealth, `default` for the stealth-derived default (Kernel's stealth proxy with `--stealth`, direct egress otherwise). Omit all proxy flags to get the default.
224225
- `--name <name>` - Optional unique name for the session (used to find it later by name; can be changed with `browsers update --name`)
225226
- `--tag <KEY=VALUE>` - Set a tag on the session, repeatable; up to 50 pairs
227+
- `--vault <id-or-name>` - Attach a project-owned vault at creation (repeatable, max 20). Requires `--project` or `KERNEL_PROJECT`. Cannot be combined with pool flags, even with `--yes`; vault bindings cannot be added to existing sessions.
226228
- `--pool-id <id>` - Acquire a browser from the specified pool (mutually exclusive with --pool-name; ignores other session flags). `--name`/`--tag` still apply to the acquired session.
227229
- `--pool-name <name>` - Acquire a browser from the pool name (mutually exclusive with --pool-id; ignores other session flags)
228230
- `--telemetry=all` - Enable telemetry for all categories
@@ -266,6 +268,108 @@ Commands with JSON output support:
266268
- `-s, --silent` - Suppress progress output
267269
- _Note: redirects are followed automatically by Chromium._
268270

271+
### Vaults
272+
273+
Vault commands **prepare and observe payment credentials; they do not submit merchant payments**.
274+
Vault names, item keys, and project ownership are immutable. Select the project explicitly with
275+
`--project <id-or-name>` or `KERNEL_PROJECT`; the API assigns ownership from that scope, not a
276+
`project_id` body field. Project-scoped credentials cannot switch projects.
277+
278+
#### Command reference
279+
280+
| Command | Purpose / flags |
281+
| --- | --- |
282+
| `kernel vaults create --name <name>` | Create or retrieve the vault with that immutable name |
283+
| `kernel vaults list` | `--limit 1..100` (default 20), `--offset`; JSON includes `vaults` and optional `next_offset` |
284+
| `kernel vaults get <vault>` | Get by ID or name |
285+
| `kernel vaults delete <vault>` | Invalidate the vault and all its items; `--yes` skips confirmation |
286+
| `kernel vaults wallets create <vault> <key> --provider link\|agentcard` | Connect/enroll a wallet; `--open` opens a returned HTTPS action URL; AgentCard optionally accepts `--user-id` for an already enrolled user in this organization |
287+
| `kernel vaults wallets payment-methods <vault> <key>` | Fetch advertised live payment methods; JSON is the item with `expanded.payment_methods` |
288+
| `kernel vaults cards create <vault> <key>` | Create a card request with the typed flags below; never implicitly authorize Link |
289+
| `kernel vaults cards update <vault> <key>` | Replace the full card spec using the same flags; the API enforces state/provider constraints |
290+
| `kernel vaults cards authorize <vault> <key>` | After explicit user approval, GET the requested Link card and POST `authorize` only if advertised; optional `--open` |
291+
| `kernel vaults items list <vault>` | List item keys, types, providers, status, and required actions |
292+
| `kernel vaults items get <vault> <key>` | Inspect state/actions/returned aliases; `--wait 0..60`, `--expand payment_methods`, `--open` |
293+
| `kernel vaults items events <vault> <key>` | Read ordered audit events; `--after <event-id>`, `--wait 0..60` |
294+
| `kernel vaults items delete <vault> <key>` | Invalidate an item; `--yes` skips confirmation |
295+
296+
`<vault>` accepts an ID or name. Names and keys use letters, digits, dots, underscores, and
297+
hyphens (1–255 characters; not `.` or `..`). All commands except delete support `-o json`.
298+
JSON preserves field presence and API-returned aliases, while omitting unknown fields,
299+
opaque metadata, and unrecognized event data. Human output labels aliases as non-secret
300+
checkout values and distinguishes card readiness from checkout authorization/payment outcomes.
301+
302+
**Card flags:** `--provider`, `--wallet <key>`, `--amount <minor-units>`, `--currency <code>`,
303+
and `--merchant <name>` are required. Currency is normalized to lowercase.
304+
305+
- **Link:** also requires `--payment-method-id`, `--merchant-url`, `--context` (at least 100
306+
characters describing the purchase), and exactly one of `--test` or `--live`. Amount is
307+
1–500000 minor units. Choose the payment-method ID from the wallet listing; capability
308+
hints are advisory, and missing hints mean unknown rather than ineligible.
309+
- **AgentCard:** optionally accepts `--card-id` from the wallet listing; otherwise the
310+
cardholder selects a card at approval. Sandbox/live mode is set by the deployment;
311+
there is no per-item test flag. AgentCard authorization happens at checkout, not through
312+
`cards authorize`. A reusable card being `ready` does not mean the last payment succeeded.
313+
- Permitted checkout domains are provider-assigned and displayed when returned. The API
314+
does **not** accept a domain-setting flag. The merchant URL is not a domain allowlist.
315+
- Advanced optional Link line items, totals, metadata, and expiry are not configurable in
316+
this initial CLI surface. `cards update` replaces the entire spec, so omitted optional
317+
details previously set through another client are removed.
318+
319+
#### Link checkout preparation
320+
321+
1. Select a project and create/select a vault. Connect the wallet in the provider's UI:
322+
323+
```bash
324+
export KERNEL_PROJECT=my-project
325+
kernel vaults create --name checkout
326+
kernel vaults wallets create checkout wallet-1 --provider link --open
327+
kernel vaults items get checkout wallet-1 --wait 60
328+
```
329+
330+
2. Once connected, list methods and explicitly choose a returned ID:
331+
332+
```bash
333+
kernel vaults wallets payment-methods checkout wallet-1
334+
kernel vaults cards create checkout order-1 \
335+
--provider link --wallet wallet-1 --payment-method-id <returned-id> \
336+
--amount 1234 --currency USD --merchant 'Example Shop' \
337+
--merchant-url https://shop.example \
338+
--context 'Purchase the selected office supplies from Example Shop for the approved order, with a total spending limit of 1234 minor currency units.' \
339+
--test
340+
```
341+
342+
3. After explicit user approval, authorize **only if the item advertises it**. Follow the
343+
returned approval action, then observe:
344+
345+
```bash
346+
kernel vaults cards authorize checkout order-1 --open
347+
kernel vaults items get checkout order-1 --wait 60
348+
```
349+
350+
4. When ready, attach the same vault to a new browser. Use only the returned
351+
`state.aliases` values in that browser's checkout and respect returned permitted domains:
352+
353+
```bash
354+
kernel browsers create --vault checkout
355+
```
356+
357+
5. Observe outcomes independently of merchant checkout submission:
358+
359+
```bash
360+
kernel vaults items get checkout order-1
361+
kernel vaults items events checkout order-1
362+
kernel vaults items events checkout order-1 --after <last-event-id> --wait 60
363+
```
364+
365+
Waits are single bounded observations, not readiness guarantees or payment retries. Pending
366+
state is returned as-is. Requests are not automatically retried by the vault commands.
367+
Pending/terminal Link authorizations cannot be resumed by `cards authorize`.
368+
**Never retry failed, timed-out, rejected, or indeterminate payments.** Inspect state/events
369+
and reconcile the outcome instead. Do not pass card data, OAuth codes/tokens, ciphertext,
370+
provider secrets, or sensitive provider responses to the CLI. Complete collection, OAuth,
371+
and approval actions through the provider's returned URL/UI; no callback-code command exists.
372+
269373
### Browser Pools
270374

271375
- `kernel browser-pools list` - List browser pools

‎cmd/browser_vaults.go‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
6+
kernel "github.com/kernel/kernel-go-sdk"
7+
)
8+
9+
func buildBrowserVaults(values []string) ([]kernel.VaultReferenceParam, error) {
10+
if len(values) > 20 {
11+
return nil, fmt.Errorf("at most 20 --vault references may be attached")
12+
}
13+
var refs []kernel.VaultReferenceParam
14+
seen := make(map[string]bool, len(values))
15+
for _, value := range values {
16+
if err := validateVaultName(value, "--vault"); err != nil {
17+
return nil, err
18+
}
19+
if seen[value] {
20+
return nil, fmt.Errorf("duplicate --vault reference")
21+
}
22+
seen[value] = true
23+
ref := kernel.VaultReferenceParam{}
24+
if cuidRegex.MatchString(value) {
25+
ref.ID = kernel.Opt(value)
26+
} else {
27+
ref.Name = kernel.Opt(value)
28+
}
29+
refs = append(refs, ref)
30+
}
31+
return refs, nil
32+
}

‎cmd/browser_vaults_test.go‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
"strings"
9+
"testing"
10+
11+
"github.com/kernel/cli/pkg/util"
12+
kernel "github.com/kernel/kernel-go-sdk"
13+
"github.com/kernel/kernel-go-sdk/option"
14+
"github.com/spf13/cobra"
15+
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
func TestBuildBrowserVaults(t *testing.T) {
20+
const id = "abcdefghijklmnopqrstuvwx"
21+
refs, err := buildBrowserVaults([]string{id, "checkout"})
22+
require.NoError(t, err)
23+
require.Len(t, refs, 2)
24+
assert.Equal(t, id, refs[0].ID.Value)
25+
assert.False(t, refs[0].Name.Valid())
26+
assert.Equal(t, "checkout", refs[1].Name.Value)
27+
assert.False(t, refs[1].ID.Valid())
28+
for _, values := range [][]string{{""}, {" "}, {"../checkout"}, {".."}, {"checkout", "checkout"}, make([]string, 21)} {
29+
_, err := buildBrowserVaults(values)
30+
require.Error(t, err)
31+
}
32+
refs, err = buildBrowserVaults(nil)
33+
require.NoError(t, err)
34+
body, err := json.Marshal(kernel.BrowserNewParams{Vaults: refs})
35+
require.NoError(t, err)
36+
assert.NotContains(t, string(body), "vaults")
37+
assert.NotNil(t, browsersCreateCmd.Flags().Lookup("vault"))
38+
assert.Nil(t, browsersUpdateCmd.Flags().Lookup("vault"))
39+
assert.False(t, poolLeaseAllowedFlags()["vault"])
40+
}
41+
42+
func browserVaultTestCommand(client kernel.Client) *cobra.Command {
43+
cmd := &cobra.Command{Use: "create"}
44+
cmd.Flags().String("project", "", "")
45+
cmd.Flags().StringArray("vault", nil, "")
46+
cmd.Flags().String("pool-id", "", "")
47+
cmd.Flags().String("pool-name", "", "")
48+
cmd.Flags().Bool("yes", false, "")
49+
addJSONOutputFlag(cmd)
50+
cmd.SetContext(context.WithValue(context.Background(), util.KernelClientKey, client))
51+
return cmd
52+
}
53+
54+
func TestBrowserVaultPoolAndProjectValidation(t *testing.T) {
55+
t.Setenv("KERNEL_PROJECT", "")
56+
client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("invalid attachment reached API") })
57+
for _, flags := range [][]string{
58+
{"--vault", "checkout", "--pool-id", "pool-1", "--yes"},
59+
{"--vault", "checkout", "--pool-name", "pool", "--yes"},
60+
{"--vault", "checkout"},
61+
{"--vault=", "--project", "project-test"},
62+
} {
63+
cmd := browserVaultTestCommand(client)
64+
require.NoError(t, cmd.ParseFlags(flags))
65+
err := runBrowsersCreate(cmd, nil)
66+
require.Error(t, err)
67+
}
68+
}
69+
70+
func TestBrowserCreateVaultRequestAndReturnedAttachments(t *testing.T) {
71+
t.Setenv("KERNEL_PROJECT", "project-test")
72+
const body = `{"session_id":"browser-1","cdp_ws_url":"ws://example.test/cdp","vaults":[{"id":"vault-1","name":"checkout"}]}`
73+
client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) {
74+
assert.Equal(t, http.MethodPost, r.Method)
75+
assert.Equal(t, "/browsers", r.URL.Path)
76+
payload, _ := io.ReadAll(r.Body)
77+
assert.JSONEq(t, `{"vaults":[{"name":"checkout"}]}`, string(payload))
78+
w.Header().Set("Content-Type", "application/json")
79+
_, _ = io.WriteString(w, body)
80+
})
81+
for _, output := range []string{"", "json"} {
82+
cmd := browserVaultTestCommand(client)
83+
require.NoError(t, cmd.Flags().Set("vault", "checkout"))
84+
require.NoError(t, cmd.Flags().Set("output", output))
85+
buf := capturePtermOutput(t)
86+
out := captureStdout(t, func() { require.NoError(t, runBrowsersCreate(cmd, nil)) })
87+
if output == "json" {
88+
assert.JSONEq(t, body, out)
89+
} else {
90+
assert.Contains(t, buf.String(), "Attached vault ID")
91+
assert.Contains(t, buf.String(), "vault-1")
92+
assert.Contains(t, buf.String(), "checkout")
93+
}
94+
}
95+
}
96+
97+
func TestBrowserCreateInvalidVaultNeverCallsSDK(t *testing.T) {
98+
b := BrowsersCmd{browsers: &FakeBrowsersService{NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) {
99+
t.Fatal("invalid vault reference should not reach SDK")
100+
return nil, nil
101+
}}}
102+
require.Error(t, b.Create(context.Background(), BrowsersCreateInput{Vaults: []string{strings.Repeat("x", 256)}}))
103+
}

0 commit comments

Comments
 (0)