Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions cmd/api_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,9 +308,9 @@ func (c APIKeysCmd) Update(ctx context.Context, in APIKeysUpdateInput) error {
return nil
}

// Rotate issues a replacement API key. The rotated key keeps working until its
// grace period elapses, so this is disruptive but not immediately breaking --
// it still prompts, since the old key does eventually stop working.
// Rotate issues a replacement API key. Grace-period and natural expiry do not
// end browser sessions. An immediate rotation does, so all rotations prompt
// because the old key stops working and the immediate form is also disruptive.
func (c APIKeysCmd) Rotate(ctx context.Context, in APIKeysRotateInput) error {
if err := validateJSONOutput(in.Output); err != nil {
return err
Expand All @@ -331,9 +331,13 @@ func (c APIKeysCmd) Rotate(ctx context.Context, in APIKeysRotateInput) error {
}

if !in.SkipConfirm {
warning := fmt.Sprintf("Are you sure you want to rotate API key '%s'? The current key stops working after its grace period.", in.ID)
if in.ExpireInDays.Set && in.ExpireInDays.Value == 0 {
warning = fmt.Sprintf("Are you sure you want to rotate API key '%s' immediately? Browser sessions attributed to the current key will end asynchronously and unsaved profile changes will be discarded.", in.ID)
}
ok, err := c.prompter.Confirm(
fmt.Sprintf("rotate API key '%s'", in.ID),
fmt.Sprintf("Are you sure you want to rotate API key '%s'? The current key stops working after its grace period.", in.ID),
warning,
)
if err != nil {
return err
Expand Down Expand Up @@ -362,7 +366,7 @@ func (c APIKeysCmd) Delete(ctx context.Context, in APIKeysDeleteInput) error {
if !in.SkipConfirm {
ok, err := c.prompter.Confirm(
fmt.Sprintf("delete API key '%s'", in.ID),
fmt.Sprintf("Are you sure you want to delete API key '%s'?", in.ID),
fmt.Sprintf("Are you sure you want to delete API key '%s'? Browser sessions attributed to it will end asynchronously and unsaved profile changes will be discarded.", in.ID),
)
if err != nil {
return err
Expand Down Expand Up @@ -584,14 +588,15 @@ var apiKeysUpdateCmd = &cobra.Command{
var apiKeysRotateCmd = &cobra.Command{
Use: "rotate <id>",
Short: "Rotate an API key",
Long: "Issue a replacement API key. The rotated key keeps working for a grace period (7 days by default) so callers can migrate; use --expire-in-days 0 to revoke it immediately.",
Long: "Issue a replacement API key. The rotated key keeps working for a grace period (7 days by default) so callers can migrate. Use --expire-in-days 0 to expire it immediately; browser sessions attributed to the old key then end asynchronously and unsaved profile changes are discarded. Grace-period and natural expiry do not end existing browser sessions. If immediate cleanup cannot be scheduled, the rotation is not completed.",
Args: cobra.ExactArgs(1),
RunE: runAPIKeysRotate,
}

var apiKeysDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete an API key",
Long: "Delete an API key and asynchronously end browser sessions attributed to it, discarding unsaved profile changes. A key cannot delete itself; use a different key. If browser cleanup cannot be scheduled, the key is not deleted.",
Args: cobra.ExactArgs(1),
RunE: runAPIKeysDelete,
}
Expand Down Expand Up @@ -625,7 +630,7 @@ func init() {

addJSONOutputFlag(apiKeysRotateCmd)
apiKeysRotateCmd.Flags().Int64("days-to-expire", 0, "Lifetime in days for the new key (1-3650); omit to reuse the rotated key's lifetime")
apiKeysRotateCmd.Flags().Int64("expire-in-days", 0, "Grace period in days before the rotated key expires; 0 expires it immediately, omit for the default 7 days")
apiKeysRotateCmd.Flags().Int64("expire-in-days", 0, "Grace period in days before the rotated key expires; 0 expires it immediately and asynchronously ends its browser sessions, omit for the default 7 days")
apiKeysRotateCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")

apiKeysDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
Expand Down
19 changes: 17 additions & 2 deletions cmd/api_keys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ import (
"github.com/stretchr/testify/require"
)

func TestAPIKeyCleanupHelpMatchesSessionBehavior(t *testing.T) {
for _, text := range []string{
apiKeysDeleteCmd.Long,
apiKeysRotateCmd.Long,
apiKeysRotateCmd.Flags().Lookup("expire-in-days").Usage,
} {
assert.Contains(t, text, "browser sessions")
}
assert.Contains(t, apiKeysDeleteCmd.Long, "unsaved profile changes")
assert.Contains(t, apiKeysDeleteCmd.Long, "key is not deleted")
assert.Contains(t, apiKeysRotateCmd.Long, "Grace-period and natural expiry do not end")
assert.Contains(t, apiKeysRotateCmd.Long, "rotation is not completed")
}

type FakeAPIKeysService struct {
NewFunc func(ctx context.Context, body kernel.APIKeyNewParams, opts ...option.RequestOption) (*kernel.CreatedAPIKey, error)
GetFunc func(ctx context.Context, id string, query kernel.APIKeyGetParams, opts ...option.RequestOption) (*kernel.APIKey, error)
Expand Down Expand Up @@ -379,8 +393,9 @@ func TestAPIKeysRotateBuildsParamsAndPrintsNewKey(t *testing.T) {
assert.Equal(t, "key_123", capturedID)
assert.True(t, captured.DaysToExpire.Valid())
assert.Equal(t, int64(30), captured.DaysToExpire.Value)
// 0 must reach the API as an explicit value, not be dropped: it means
// "revoke the rotated key now" rather than "use the default grace period".
// 0 must reach the API as an explicit value, not be dropped: it expires
// the rotated key and asynchronously ends its browser sessions rather than
// using the default grace period.
assert.True(t, captured.ExpireInDays.Valid())
assert.Equal(t, int64(0), captured.ExpireInDays.Value)

Expand Down
7 changes: 6 additions & 1 deletion cmd/proxies/proxy_ips.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func init() {
ProxyIPsCmd.AddCommand(proxyIPsListCmd, proxyIPsGetCmd)

proxyIPsListCmd.Flags().Int("page", 1, "Page number (1-based)")
proxyIPsListCmd.Flags().Int("per-page", 20, "Items per page")
proxyIPsListCmd.Flags().Int("per-page", 20, "Items per page (1-999)")
proxyIPsListCmd.Flags().String("country", "", "Filter by an ISP-supported ISO 3166 country code")
proxyIPsListCmd.Flags().String("status", "", "Filter by assignability (active|excluded)")
addJSONOutputFlag(proxyIPsListCmd)
Expand All @@ -73,6 +73,11 @@ func (p ProxyIPCmd) List(ctx context.Context, in ProxyIPListInput) error {
if perPage <= 0 {
perPage = 20
}
// The API accepts at most 1000 items. Reserve one item for the pagination
// lookahead used to determine whether another page exists.
if perPage > 999 {
return fmt.Errorf("--per-page must be between 1 and 999")
}

params := kernel.ProxyIPListParams{
Limit: kernel.Opt(int64(perPage + 1)),
Expand Down
32 changes: 32 additions & 0 deletions cmd/proxies/proxy_ips_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,37 @@ func TestProxyIPListUsesPagePaginationAndPreservesFilters(t *testing.T) {
assert.Contains(t, out, `Next: kernel proxy-ips list --page 3 --per-page 2 --country "US" --status "active"`)
}

func TestProxyIPListUsesMaximumPageSizeWithLookahead(t *testing.T) {
var captured kernel.ProxyIPListParams
fake := &fakeProxyIPService{
listFunc: func(_ context.Context, params kernel.ProxyIPListParams, _ ...option.RequestOption) (*pagination.OffsetPagination[kernel.ProxyIP], error) {
captured = params
return &pagination.OffsetPagination[kernel.ProxyIP]{}, nil
},
}

err := ProxyIPCmd{proxyIPs: fake}.List(context.Background(), ProxyIPListInput{Page: 2, PerPage: 999, Output: "json"})

require.NoError(t, err)
assert.Equal(t, int64(1000), captured.Limit.Value)
assert.Equal(t, int64(999), captured.Offset.Value)
}

func TestProxyIPListRejectsPageSizeAboveLookaheadLimit(t *testing.T) {
called := false
fake := &fakeProxyIPService{
listFunc: func(_ context.Context, _ kernel.ProxyIPListParams, _ ...option.RequestOption) (*pagination.OffsetPagination[kernel.ProxyIP], error) {
called = true
return nil, nil
},
}

err := ProxyIPCmd{proxyIPs: fake}.List(context.Background(), ProxyIPListInput{PerPage: 1000})

assert.EqualError(t, err, "--per-page must be between 1 and 999")
assert.False(t, called)
}

func TestProxyIPListPrintsFooterWhenEmpty(t *testing.T) {
buf := captureOutput(t)

Expand Down Expand Up @@ -126,6 +157,7 @@ func TestProxyIPCommandsExposePageBasedFlags(t *testing.T) {
assert.NotNil(t, ProxyIPsCmd.Commands())
assert.NotNil(t, proxyIPsListCmd.Flags().Lookup("page"))
assert.NotNil(t, proxyIPsListCmd.Flags().Lookup("per-page"))
assert.Contains(t, proxyIPsListCmd.Flags().Lookup("per-page").Usage, "1-999")
assert.Nil(t, proxyIPsListCmd.Flags().Lookup("limit"))
assert.Nil(t, proxyIPsListCmd.Flags().Lookup("offset"))
assert.NotNil(t, proxyIPsListCmd.Flags().Lookup("country"))
Expand Down
12 changes: 7 additions & 5 deletions cmd/vaults_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,16 @@ const linkCardFlowHelp = `Link card checkout and payment_request checkout (each
3. once the user has approved this purchase, authorize with items invoke <vault> <key>
authorize --browser-id <browser-session-id> --page-url <exact current top-level checkout
url>. KERNEL inspects the checkout, chooses how the credential is delivered (a Link Pay
Token or a virtual card), limits fill to that checkout's origin, and sends the user an
approval request. do not inspect or invoke WebMCP yourself.
Token or a virtual card), records card or link_pay_token in state.credential_type after
authorize, limits fill to that checkout's origin, and sends the user an approval request.
callers do not choose the delivery type; do not inspect or invoke WebMCP yourself.
4. share the approval url from the item's action with the user, then use items get --wait 60
until fill is advertised. to re-check sooner, invoke authorize again; checkout context is
not needed, and re-checking never creates another payment request.
5. fill as the item's fill description says, in any browser with this vault attached: a Link
Pay Token takes browser_id and page_url and omits fields; a virtual card also takes
field/selector bindings.
5. fill as the item's fill description says, in any browser with this vault attached:
link_pay_token takes browser_id and page_url and omits fields; card also takes
field/selector bindings. state.credential_type is absent before authorize and after Link
rejects the request; a ready item without it fills like card.
6. submit the checkout yourself; fill never submits payment. failed or unknown fills are safe
to retry, but only after checking the page and not in an automatic loop.
if authorize returns page_not_found, ambiguous_page, browser_not_found, browser_unavailable,
Expand Down
6 changes: 5 additions & 1 deletion cmd/vaults_link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ func TestLinkCardFlowHelpOrder(t *testing.T) {
}
for _, want := range []string{
"a Link Pay Token or a virtual card",
"a Link Pay Token takes browser_id and page_url and omits fields",
"records card or link_pay_token in state.credential_type after authorize",
"callers do not choose the delivery type",
"link_pay_token takes browser_id and page_url and omits fields",
"state.credential_type is absent before authorize and after Link rejects the request",
"a ready item without it fills like card",
"page_not_found, ambiguous_page, browser_not_found, browser_unavailable, destination_denied, timeout, or browser_error",
"make sure the checkout is open in exactly one tab at page_url, then authorize again",
"authorize without --browser-id and --page-url to issue a virtual card",
Expand Down
13 changes: 12 additions & 1 deletion cmd/vaults_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ var vaultItemFields = vaultOutputFields{
},
},
"state": {
"provider": nil, "status": nil, "status_reason": nil, "user_id": nil, "domains": nil, "access_request_id": nil,
"provider": nil, "status": nil, "status_reason": nil, "credential_type": nil, "user_id": nil, "domains": nil, "access_request_id": nil,
"access_request": {
"id": nil, "state": nil, "goal": nil, "createdAt": nil, "has_autofill_token": nil, "granted_count": nil,
"request": onePasswordRequestFields, "entries": onePasswordRequestEntryFields,
Expand Down Expand Up @@ -465,6 +465,9 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error {
if item.State.StatusReason != "" {
rows = append(rows, []string{"status reason", item.State.StatusReason})
}
if item.State.JSON.CredentialType.Valid() && item.State.CredentialType != "" {
rows = append(rows, []string{"credential type", item.State.CredentialType})
}
if item.Type == "card" || item.Type == "payment_request" {
merchant := item.Spec.MerchantName
if item.Spec.Provider == "agentcard" {
Expand Down Expand Up @@ -662,6 +665,14 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction
}
}
case "link":
switch {
case item.State.JSON.CredentialType.Valid() && item.State.CredentialType == "link_pay_token":
pterm.Info.Println("credential type link_pay_token: invoke fill without field bindings; KERNEL supplies the Link Pay Token through the checkout's Link WebMCP tool.")
case item.State.JSON.CredentialType.Valid() && item.State.CredentialType == "card":
pterm.Info.Println("credential type card: invoke fill with the field bindings advertised by the item.")
case item.State.Status == "ready":
pterm.Info.Println("no credential type was returned; a ready Link item without state.credential_type fills like card, so use the advertised card field bindings.")
}
pterm.Info.Println("fill never submits payment; submit the checkout yourself. if the result of submitting payment is unclear, check the merchant's order confirmation or order history before paying again. if authorization is declined or expires, delete this purchase item and create a new one.")
if item.Spec.JSON.Recurring.Valid() {
pterm.Info.Println("this recurring Link purchase remains ready for merchant-scheduled charges until it expires or is deleted; do not create a new card each period, or a new payment_request for the same recurring purchase. deleting the item, wallet, or vault revokes the Link credential but does not cancel the merchant subscription. if deletion returns 409 provider_revocation_pending, retry deletion.")
Expand Down
42 changes: 41 additions & 1 deletion cmd/vaults_output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const readyAgentCardFixture = `{
const readyLinkCardFixture = `{
"id":"card-id","key":"order-1","type":"card",
"spec":{"provider":"link","wallet":"wallet-1","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example","provider_secret":"SECRET_SPEC"},
"state":{"provider":"link","status":"ready","domains":["shop.example"],"masks":{"brand":"visa","last4":"1234"},"card_number":"SECRET_CARD","secret_enc":"SECRET_CIPHERTEXT"},
"state":{"provider":"link","status":"ready","credential_type":"card","domains":["shop.example"],"masks":{"brand":"visa","last4":"1234"},"card_number":"SECRET_CARD","secret_enc":"SECRET_CIPHERTEXT"},
"available_operations":[{"type":"fill","description":"Fill checkout fields."}],"available_expansions":[],"oauth_tokens":"SECRET_OAUTH"
}`

Expand Down Expand Up @@ -84,6 +84,8 @@ func TestVaultOutputLinkHasNoAliases(t *testing.T) {
operation := "authorize"
if item.State.Status == "ready" {
operation = "fill"
assert.Contains(t, buf.String(), "credential type")
assert.Contains(t, buf.String(), "credential type card")
assert.Contains(t, buf.String(), "permitted domains (provider-assigned)")
assert.Contains(t, buf.String(), "shop.example")
}
Expand All @@ -97,13 +99,51 @@ func TestVaultOutputLinkHasNoAliases(t *testing.T) {
require.NoError(t, json.Unmarshal([]byte(out), &decoded))
assert.JSONEq(t, fmt.Sprintf("%q", item.State.Status), string(decoded.State["status"]))
if item.State.Status == "ready" {
assert.JSONEq(t, `"card"`, string(decoded.State["credential_type"]))
assert.JSONEq(t, `["shop.example"]`, string(decoded.State["domains"]))
assert.JSONEq(t, `{"brand":"visa","last4":"1234"}`, string(decoded.State["masks"]))
}
})
}
}

func TestVaultOutputLinkCredentialTypeVariants(t *testing.T) {
for _, tc := range []struct {
name string
fixture string
want []string
absent bool
}{
{
name: "link pay token",
fixture: strings.Replace(readyLinkCardFixture, `"credential_type":"card"`, `"credential_type":"link_pay_token"`, 1),
want: []string{"credential type", "link_pay_token", "without field bindings", "Link WebMCP tool"},
},
{
name: "ready legacy response",
fixture: strings.Replace(readyLinkCardFixture, `,"credential_type":"card"`, "", 1),
want: []string{"no credential type was returned", "fills like card", "card field bindings"},
absent: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
var item kernel.VaultItemUnion
require.NoError(t, json.Unmarshal([]byte(tc.fixture), &item))
buf := capturePtermOutput(t)
require.NoError(t, printVaultItem(&item, ""))
for _, want := range tc.want {
assert.Contains(t, buf.String(), want)
}
out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) })
if tc.absent {
assert.NotContains(t, out, `"credential_type"`)
} else {
assert.Contains(t, out, `"credential_type": "link_pay_token"`)
}
})
}
}

func TestVaultOutputKernelCardMerchantCountry(t *testing.T) {
var item kernel.VaultItemUnion
require.NoError(t, json.Unmarshal([]byte(`{
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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.144.0
github.com/kernel/kernel-go-sdk v0.145.0
github.com/klauspost/compress v1.18.5
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/pterm/pterm v0.12.80
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,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.144.0 h1:KxburLFDs4Dlm/tvX8xFLPZz7DJRAQpy6WUrFRGnDO0=
github.com/kernel/kernel-go-sdk v0.144.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U=
github.com/kernel/kernel-go-sdk v0.145.0 h1:IJDd9I1nxpSMWlnVDqqJuieLhVJAz8dK0rYSrHFn1WE=
github.com/kernel/kernel-go-sdk v0.145.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U=
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=
Expand Down
Loading