diff --git a/cmd/api_keys.go b/cmd/api_keys.go index a4eb39f3..87b363e8 100644 --- a/cmd/api_keys.go +++ b/cmd/api_keys.go @@ -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 @@ -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 @@ -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 @@ -584,7 +588,7 @@ var apiKeysUpdateCmd = &cobra.Command{ var apiKeysRotateCmd = &cobra.Command{ Use: "rotate ", 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, } @@ -592,6 +596,7 @@ var apiKeysRotateCmd = &cobra.Command{ var apiKeysDeleteCmd = &cobra.Command{ Use: "delete ", 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, } @@ -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") diff --git a/cmd/api_keys_test.go b/cmd/api_keys_test.go index 9427d874..d2b25367 100644 --- a/cmd/api_keys_test.go +++ b/cmd/api_keys_test.go @@ -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) @@ -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) diff --git a/cmd/proxies/proxy_ips.go b/cmd/proxies/proxy_ips.go index 3a549a21..4101a29f 100644 --- a/cmd/proxies/proxy_ips.go +++ b/cmd/proxies/proxy_ips.go @@ -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) @@ -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)), diff --git a/cmd/proxies/proxy_ips_test.go b/cmd/proxies/proxy_ips_test.go index 327d132f..6779096a 100644 --- a/cmd/proxies/proxy_ips_test.go +++ b/cmd/proxies/proxy_ips_test.go @@ -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) @@ -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")) diff --git a/cmd/vaults_link.go b/cmd/vaults_link.go index e7c1d670..93987029 100644 --- a/cmd/vaults_link.go +++ b/cmd/vaults_link.go @@ -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 authorize --browser-id --page-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, diff --git a/cmd/vaults_link_test.go b/cmd/vaults_link_test.go index ab77cbfe..bfd05840 100644 --- a/cmd/vaults_link_test.go +++ b/cmd/vaults_link_test.go @@ -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", diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index b8fe981f..5da70ae0 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -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, @@ -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" { @@ -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.") diff --git a/cmd/vaults_output_test.go b/cmd/vaults_output_test.go index f09c2ffe..64475ac2 100644 --- a/cmd/vaults_output_test.go +++ b/cmd/vaults_output_test.go @@ -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" }` @@ -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") } @@ -97,6 +99,7 @@ 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"])) } @@ -104,6 +107,43 @@ func TestVaultOutputLinkHasNoAliases(t *testing.T) { } } +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(`{ diff --git a/go.mod b/go.mod index ff9e9dc7..6a28ebc3 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.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 diff --git a/go.sum b/go.sum index 22de7dcc..d01fa942 100644 --- a/go.sum +++ b/go.sum @@ -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=