diff --git a/README.md b/README.md index 82d8fa20..4ae4a096 100644 --- a/README.md +++ b/README.md @@ -456,7 +456,7 @@ TypeScript-style types, which must stay in sync with the [API spec](https://api. `card_enrollment` action collects card details from the cardholder, never from the CLI. - **Link wallet:** supply `authorization: {method: "oauth", client: {type: "kernel_managed"}}`. - **AgentCard wallet:** use `{}` to enroll, or supply `user_id` for a user enrolled in the same organization and provider configuration. -- **Kernel card:** supply `wallet`, `amount` (minor units, at most 50000), `currency`, +- **Kernel card:** supply `wallet`, `amount` (minor units, at most 1000000), `currency`, `merchant_name`, and HTTPS `merchant_url`. Supply two-letter `merchant_country` for Visa. Kernel card updates are unsupported. - **Link card:** include the required fields shown in help. Optional `line_items`, `totals`, @@ -637,7 +637,7 @@ kernel vaults items invoke checkout order-1 confirm_transaction --params '{ `type` or any extra properties. `status` must be `APPROVED`, `DECLINED`, `PENDING`, `ERROR`, or `CANCELLED`. `transaction_type` must be `PURCHASE`, `AUTHORIZATION`, `CAPTURE`, `REFUND`, `REVERSAL`, `VERIFICATION`, `CHARGEBACK`, or `FRAUD`. `amount` is an integer in minor units, -0..50000 and no greater than the approved amount; `currency` is three uppercase letters +0..1000000 and no greater than the approved amount; `currency` is three uppercase letters matching the approved purchase currency; `occurred_at` is the observed outcome's RFC3339 timestamp. The operation is no longer advertised after reporting. Never infer success from fill. `PENDING` means explicit @@ -882,7 +882,8 @@ kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"browse stored billing fields. `billing_*` returns 400 `field_unavailable` before any write; enter billing details on the merchant page. Fill is restricted to `merchant_url`'s origin. - 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. +- Deprecated `timeout_ms` is accepted for compatibility as an integer from 1 to 30000, + but the API ignores it. Fill sets no operation deadline and adapts typing pace to value length. Fill returns an execution result, **not an updated item**. Normal output shows zero-based field indices, statuses, and error codes. `-o json` preserves the display-safe result shape: diff --git a/cmd/browsers_telemetry_test.go b/cmd/browsers_telemetry_test.go index 0a639730..99bf719b 100644 --- a/cmd/browsers_telemetry_test.go +++ b/cmd/browsers_telemetry_test.go @@ -60,7 +60,7 @@ func (f *FakeBrowserTelemetryService) EventsAutoPaging(ctx context.Context, id s if f.EventsAutoPagingFunc != nil { return f.EventsAutoPagingFunc(id, query, opts...) } - return pagination.NewOffsetPaginationAutoPager(&pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse]{}, nil) + return pagination.NewOffsetPaginationAutoPager[kernel.BrowserTelemetryEventsResponse](nil, nil) } func TestTelemetryStream_NilTelemetryErrors(t *testing.T) { @@ -745,7 +745,7 @@ func TestTelemetryEvents_FullScanIgnoresOffsetUsesSince(t *testing.T) { fakeTelemetry := &FakeBrowserTelemetryService{ EventsAutoPagingFunc: func(id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) *pagination.OffsetPaginationAutoPager[kernel.BrowserTelemetryEventsResponse] { gotQuery = query - return pagination.NewOffsetPaginationAutoPager(&pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse]{}, nil) + return pagination.NewOffsetPaginationAutoPager[kernel.BrowserTelemetryEventsResponse](nil, nil) }, } b := BrowsersCmd{browsers: fakeBrowsers, telemetry: fakeTelemetry} diff --git a/cmd/vaults.go b/cmd/vaults.go index 79218f87..91495c55 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -196,14 +196,27 @@ func validateVaultWait(wait int64) error { return nil } +var vaultItemExpansionTypes = []string{"payment_methods", "link_user_info", "link_shipping_addresses"} + +func validateVaultItemExpansions(expand []string) error { + allowed := make(map[string]bool, len(vaultItemExpansionTypes)) + for _, field := range vaultItemExpansionTypes { + allowed[field] = true + } + for _, field := range expand { + if !allowed[field] { + return fmt.Errorf("--expand must contain only: %s", strings.Join(vaultItemExpansionTypes, ", ")) + } + } + return nil +} + func (c VaultsCmd) GetItem(ctx context.Context, vault, key string, wait int64, expand []string, project, output string, open bool) error { if err := validateVaultWait(wait); err != nil { return err } - for _, field := range expand { - if field != "payment_methods" { - return fmt.Errorf("--expand only supports payment_methods") - } + if err := validateVaultItemExpansions(expand); err != nil { + return err } ctx, cancel := context.WithTimeout(ctx, time.Duration(wait)*time.Second+30*time.Second) defer cancel() @@ -264,6 +277,9 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if operation == "confirm_transaction" && (params == nil || params.ConfirmTransaction == nil || open) { return fmt.Errorf("confirm_transaction requires --params and does not support --open") } + if isPasskeyOperation(operation) && (params == nil || params.Passkey == nil || open) { + return fmt.Errorf("%s requires --browser-id and does not support --open", operation) + } if isOnePasswordOperation(operation) && (params == nil || params.OnePassword == nil) { return fmt.Errorf("%s requires its documented parameters", operation) } @@ -334,6 +350,9 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par return err } opts = append(opts, option.WithRequestBody("application/json", body)) + } else if params != nil && params.Passkey != nil { + request = *params.Passkey + request.IDOrName = vault } else if params != nil && params.OnePassword != nil { request = *params.OnePassword request.IDOrName = vault @@ -366,6 +385,9 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par } return util.CleanedUpSdkError{Err: err} } + if isPasskeyOperation(operation) { + return printVaultPasskeyOperationResult(response, operation, output) + } if response == nil || (response.Type != "card" && response.Type != "wallet" && response.Type != "credential" && response.Type != "credential_account") { if item.Spec.Provider == "kernel" { return fmt.Errorf("unexpected vault operation response; %s", kernelVaultRetryGuidance(operation)) diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 3e0b9c9c..0e958b43 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -67,6 +67,8 @@ KERNEL-hosted credential flow (fill never submits website forms; WebMCP tools ma browser_id and field selectors (writes fields without submitting). when the item advertises webmcp_invoke, items webmcp invoke instead binds credential fields to existing null inputs of a live WebMCP tool (the tool may submit or have side effects). + passkey fields are never filled: use register_passkey to create one in a vault-bound + browser, arm_passkey to sign in, and disarm_passkey as soon as the task is done. fill never submits, so a retry is safe. retry only after checking the page, and never in an automatic loop. use credentials update --version for edits, or items invoke collect to reopen the form. @@ -164,14 +166,15 @@ each vault has its own key; it is created on first request and may be cached.`, addJSONOutputFlag(encryptionKey) cmd.AddCommand(create, list, get, encryptionKey, newVaultDeleteCommand(false)) - items := &cobra.Command{Use: "items", Short: "inspect readiness and collection urls, or invoke collect/fill/webmcp_invoke", 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.\nuse webmcp invoke to call a live WebMCP tool with item fields bound to null input slots.\n1Password credentials use the advertised 1pw_* operations instead of collect/fill/webmcp_invoke.\ncreate and edit credentials with vaults credentials; payment items use wallets/cards."} + items := &cobra.Command{Use: "items", Short: "inspect item readiness or invoke advertised credential and payment operations", Long: "use get --wait 60 to observe readiness and get -o json for schema/version/presence.\nuse invoke collect to obtain a collection url, invoke fill --spec-file to fill a browser, or invoke advertised passkey operations with --browser-id.\nuse webmcp invoke to call a live WebMCP tool with item fields bound to null input slots.\n1Password credentials use the advertised 1pw_* operations instead of collect/fill/webmcp_invoke.\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 AgentCard 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. explicitly non-sensitive text/email values are returned; sensitive values and totp seeds are omitted.\nrecovery_required stops waiting and means unresolved, not declined or expired.\n" + vaultRecoveryHelp, + itemGet := &cobra.Command{Use: "get ", Short: "get item state, available operations, and requested live data", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: "get item state, available operations, provider actions, and returned AgentCard checkout aliases.\n--expand fetches live, non-persisted provider data only when the item advertises that expansion; requesting unavailable data fails rather than returning a partial item. Link user info and shipping addresses can contain personal data. use -o json to preserve null versus omitted fields.\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. explicitly non-sensitive text/email values are returned; sensitive values and totp seeds are omitted.\nrecovery_required stops waiting and means unresolved, not declined or expired.\n" + vaultRecoveryHelp, + Example: " kernel vaults items get checkout wallet-1 --expand payment_methods\n kernel vaults items get checkout wallet-1 --expand link_user_info,link_shipping_addresses -o json", RunE: func(cmd *cobra.Command, args []string) error { wait, _ := cmd.Flags().GetInt64("wait") expand, _ := cmd.Flags().GetStringSlice("expand") @@ -180,7 +183,7 @@ each vault has its own key; it is created on first request and may be cached.`, return getVaultsHandler(cmd).GetItem(cmd.Context(), args[0], args[1], wait, expand, resolveProjectSelection(project), vaultOutput(cmd), open) }} itemGet.Flags().Int64("wait", 0, "hold while pending for up to this many seconds (0-60); observe only") - itemGet.Flags().StringSlice("expand", nil, "advertised live data to fetch: payment_methods") + itemGet.Flags().StringSlice("expand", nil, "advertised live data to fetch: payment_methods, link_user_info, or link_shipping_addresses") itemGet.Flags().Bool("open", false, "open a returned https action url; cardholder links are human-only, agents must share privately") addVaultJSONOutputFlag(itemGet) itemEvents := &cobra.Command{Use: "events ", Short: "read immutable item events without retrying payments", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, @@ -209,7 +212,7 @@ omit page_url only when the api can resolve a unique page. totp codes stay serve cards require an exact https page_url. Link virtual 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. expiration requires format MM/YY -or MM/YYYY. optional timeout_ms is 1-30000 (default 10000). +or MM/YYYY. deprecated timeout_ms is accepted for compatibility but ignored; fill sets no operation deadline and adapts typing pace to value length. kernel cards support number, expiration (format MM/YY or MM/YYYY), cvc, exp_month, and exp_year. kernel cards have no stored billing fields: billing_* returns field_unavailable (400) before any write. enter billing details on the merchant page. @@ -241,7 +244,7 @@ preparations are single-use, including after failure or expiry; never retry auto confirm_transaction requires --params or --spec-file without type, with all of: status (APPROVED, DECLINED, PENDING, ERROR, CANCELLED), transaction_type (PURCHASE, AUTHORIZATION, CAPTURE, REFUND, REVERSAL, VERIFICATION, CHARGEBACK, FRAUD), amount -(integer minor units 0..50000, no greater than the approved amount), currency (three +(integer minor units 0..1000000, no greater than the approved amount), currency (three uppercase letters matching the approved purchase currency), and occurred_at (RFC3339). no other fields are accepted. ` + kernelTransactionHelp + `. restart_verification takes no parameters. in recovery_required, ` + vaultRecoveryHelp + ` @@ -253,6 +256,8 @@ agents must omit --open and share capability links privately, never open them fo fill returns value-free per-field outcomes; completed exits 0, failed/unknown exit nonzero with valid json retained on stdout in -o json. +` + vaultPasskeyOperationHelp + ` + webmcp_invoke (credential items and ready Link cards when advertised; see items webmcp invoke --help for the flag-based form) requires browser_id, the opaque live tool_ref and exact source.page_url from browsers webmcp list, input (public json object with a null at each bound slot; never vault @@ -300,6 +305,9 @@ JSON kernel vaults items invoke user-vault github 1pw_access_request_status --params '{"timeout_seconds":60}' kernel vaults items invoke user-vault github 1pw_fill --params '{"browser_id":"","page_url":"https://github.com/login"}' kernel vaults items invoke user-vault github 1pw_fill --params '{"browser_id":"","page_url":"https://github.com/login","entry_id":""}' + kernel vaults items invoke user-vault login register_passkey --browser-id --field passkey + kernel vaults items invoke user-vault login arm_passkey --browser-id --field passkey + kernel vaults items invoke user-vault login disarm_passkey --browser-id kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"browser-session-id","page_url":"https://shop.example/checkout","fields":[{"field":"number","selector":"#cardNumber"},{"field":"expiration","format":"MM/YY","selector":"#cardExpiry"},{"field":"cvc","selector":"#cardCvc"}]}' -o json kernel vaults items invoke checkout order-1 confirm_transaction --params '{"status":"APPROVED","transaction_type":"PURCHASE","amount":100,"currency":"USD","occurred_at":"2026-09-01T12:00:00Z"}' kernel vaults items invoke checkout order-1 restart_verification --open`, @@ -312,7 +320,7 @@ JSON } if cmd.Flags().Changed("spec-file") { if !vaultOperationTakesParams(args[2]) { - return fmt.Errorf("--spec-file is only supported for fill, webmcp_invoke, prepare_checkout, confirm_transaction, and 1Password operations with parameters") + return fmt.Errorf("--spec-file is only supported for fill, webmcp_invoke, prepare_checkout, confirm_transaction, and 1Password operations with parameters; passkey operations use --browser-id and optional --field") } data, err := readVaultJSONFile(cmd, "spec-file") if err != nil { @@ -324,11 +332,17 @@ JSON if err != nil { return err } + passkey, err := parseVaultPasskeyOperationFlags(cmd, args[2]) + if err != nil { + return err + } authorize, err := parseVaultAuthorizeCheckout(cmd, args[2]) if err != nil { return err } - if authorize != nil { + if passkey != nil { + params = &vaultOperationParams{Passkey: passkey} + } else if authorize != nil { params = &vaultOperationParams{Authorize: authorize} } return getVaultsHandler(cmd).Invoke(cmd.Context(), args[0], args[1], args[2], params, vaultOutput(cmd), open) @@ -337,8 +351,9 @@ JSON invoke.Flags().String("spec-file", "", "operation 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; cardholder links are human-only, agents must share privately") - invoke.Flags().String("browser-id", "", "authorize a Link card at checkout: browser session id with this vault attached (requires --page-url)") - invoke.Flags().String("page-url", "", "authorize a Link card at checkout: exact current top-level checkout url open in --browser-id (requires --browser-id)") + invoke.Flags().String("browser-id", "", "browser session id, not a reusable name; used by Link authorize (with --page-url) and passkey operations") + invoke.Flags().String("page-url", "", "Link authorize only: exact current top-level checkout url open in --browser-id") + invoke.Flags().String("field", "", "passkey field to arm, receive a registration, or disarm; arm/register require it when ambiguous, while disarm omits it to affect every field") addVaultJSONOutputFlag(invoke) items.AddCommand(itemList, itemGet, itemEvents, invoke, newVaultWebMCPCommand(), newVaultDeleteCommand(true)) diff --git a/cmd/vaults_confirm_transaction.go b/cmd/vaults_confirm_transaction.go index 4eb74ad8..c1e1c80b 100644 --- a/cmd/vaults_confirm_transaction.go +++ b/cmd/vaults_confirm_transaction.go @@ -32,8 +32,8 @@ func parseVaultConfirmTransactionParams(raw string) (*vaultConfirmTransactionPar return nil, fmt.Errorf("transaction_type must be PURCHASE, AUTHORIZATION, CAPTURE, REFUND, REVERSAL, VERIFICATION, CHARGEBACK, or FRAUD") } var amount *int64 - if json.Unmarshal(object["amount"], &amount) != nil || amount == nil || *amount < 0 || *amount > 50000 { - return nil, fmt.Errorf("amount must be an integer between 0 and 50000 minor units, no greater than the approved amount") + if json.Unmarshal(object["amount"], &amount) != nil || amount == nil || *amount < 0 || *amount > 1_000_000 { + return nil, fmt.Errorf("amount must be an integer between 0 and 1000000 minor units, no greater than the approved amount") } params.Amount = *amount if json.Unmarshal(object["currency"], ¶ms.Currency) != nil || !vaultCurrencyPattern.MatchString(params.Currency) { diff --git a/cmd/vaults_confirm_transaction_test.go b/cmd/vaults_confirm_transaction_test.go index 5e726a94..7feed3c0 100644 --- a/cmd/vaults_confirm_transaction_test.go +++ b/cmd/vaults_confirm_transaction_test.go @@ -78,7 +78,7 @@ func TestVaultConfirmTransactionInvalidParamsDoNotRequest(t *testing.T) { for field, values := range map[string][]string{ "status": {`"approved"`, `"UNKNOWN"`, `42`}, "transaction_type": {`"purchase"`, `"SALE"`, `false`}, - "amount": {`-1`, `50001`, `100.5`, `100.0`, `"100"`, `9223372036854775808`}, + "amount": {`-1`, `1000001`, `100.5`, `100.0`, `"100"`, `9223372036854775808`}, "currency": {`"usd"`, `"US"`, `"USDD"`, `"U1D"`, `123`}, "occurred_at": {`"2026-09-01"`, `"2026-09-01T12:00:00"`, `"not-a-date"`, `42`}, } { @@ -110,7 +110,7 @@ func TestVaultConfirmTransactionInvalidParamsDoNotRequest(t *testing.T) { func TestVaultConfirmTransactionValidValues(t *testing.T) { for _, status := range []string{"APPROVED", "DECLINED", "PENDING", "ERROR", "CANCELLED"} { for _, transactionType := range []string{"PURCHASE", "AUTHORIZATION", "CAPTURE", "REFUND", "REVERSAL", "VERIFICATION", "CHARGEBACK", "FRAUD"} { - for _, amount := range []int{0, 50000} { + for _, amount := range []int{0, 1_000_000} { raw := fmt.Sprintf(`{"status":%q,"transaction_type":%q,"amount":%d,"currency":"USD","occurred_at":"2026-09-01T12:00:00.123+01:00"}`, status, transactionType, amount) params, err := parseVaultOperationParams("confirm_transaction", raw, true, false) require.NoError(t, err) @@ -120,6 +120,13 @@ func TestVaultConfirmTransactionValidValues(t *testing.T) { } } +func TestVaultConfirmTransactionHelpUsesCurrentAmountMaximum(t *testing.T) { + cmd, _, err := newVaultsCommand().Find([]string{"items", "invoke"}) + require.NoError(t, err) + assert.Contains(t, cmd.Long, "0..1000000") + assert.NotContains(t, cmd.Long, "0..50000") +} + func TestVaultConfirmTransactionDoesNotRetry(t *testing.T) { for _, status := range []int{http.StatusTooManyRequests, http.StatusInternalServerError} { t.Run(fmt.Sprint(status), func(t *testing.T) { diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index f67f1c40..7341b1a8 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -25,9 +25,11 @@ const vaultCredentialPathsHelp = `credential vaults have two sign-in paths. befo site's fields, the user types values into a KERNEL-hosted form at the returned collection url, and KERNEL stores them encrypted. once ready, items invoke fill writes them into ordinary web form fields in a vault-bound browser without - submitting. when the item advertises webmcp_invoke, items webmcp invoke instead - binds credential fields to existing null inputs of a live WebMCP tool; the tool - may submit or have other side effects. + submitting. passkeys are never filled: invoke register_passkey to create one in + an empty passkey field, arm_passkey to sign in, and disarm_passkey afterward. + when the item advertises webmcp_invoke, items webmcp invoke instead binds + credential fields to existing null inputs of a live WebMCP tool; the tool may + submit or have other side effects. - 1Password brokered approval (spec provider "1password", preview): requires the login to be in the user's own, non-shared 1Password vault; shared-vault items and passkeys are not supported. state this requirement when asking. the account @@ -106,25 +108,35 @@ fields as an ordered array of named definitions. inspect the website and list fields in its natural top-to-bottom order because the user-facing collection form renders that order unchanged. definitions accept a stable name and an optional non-secret human-readable label; forms fall back to name. updates, -state, and browser fills always use name. field types are text, email, password, and -totp; definitions also accept required, sensitive, and value. +state, and browser fills always use name; passkey operations use the same name. field +types are text, email, password, totp, and passkey; definitions also accept required, +sensitive, and value. text, email, and password have form inputs. totp and passkey do +not, so both KERNEL-hosted and customer-hosted collection forms omit them. custom collection web apps that encrypt in the browser may send encrypted_value (a compact jwe to the key from vaults get-encryption-key) instead of value; never both. +a passkey value is a JSON-encoded FIDO Credential Exchange Format object with unpadded +base64url credentialId, a userHandle of at most 64 bytes, a PKCS#8 ECDSA P-256 private +key, a lowercase rpId domain, and optional username and userDisplayName. other CXF +members are ignored. alternatively, declare an optional empty passkey field and use +register_passkey in a vault-bound browser; only ES256 passkeys are created. 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. +reserve sensitive:true for secrets such as passwords, api tokens, totp seeds, and +passkeys. password, totp, and passkey must be sensitive. omitted sensitive defaults to +true for safety. omit required form-supported values to receive a collection url to +present to the user; an empty required totp or passkey cannot be collected in that form. poll items get --wait 60 until state.status is ready. then choose an advertised operation: - ordinary web forms: items invoke fill writes field values without submitting. +- passkeys: invoke register_passkey to create one in an empty field, arm_passkey to + answer matching sign-in requests, and disarm_passkey as soon as the task is done. - live WebMCP tool: when webmcp_invoke is advertised, run browsers webmcp list, then items webmcp invoke with the tool_ref, exact source.page_url, public input containing null slots, and --bind =. the tool may submit or have other side effects, and its output may include the supplied values. fill never submits, so a retry is safe. retry only after checking the page, and never in an automatic loop. 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. +can read filled values. totp seeds and passkeys must not be collected through the hosted form. get/list output includes definitions, has_value, and explicitly non-sensitive text/email values. sensitive values and totp seeds are omitted. collection urls are bearer credentials: share only with the intended user. @@ -158,7 +170,7 @@ func newVaultCredentialsCommand() *cobra.Command { }, } if update { - cmd.Long += "\nupdate applies to KERNEL-hosted credentials only. update spec fields are an object keyed by field name, not the ordered array used on create.\nupdate preserves omitted fields, replaces nonempty string values (value, or encrypted_value from a browser-encrypting collection app), 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.Long += "\nupdate applies to KERNEL-hosted credentials only. update spec fields are an object keyed by field name, not the ordered array used on create.\nupdate preserves omitted fields, replaces nonempty string values (value, or encrypted_value from a browser-encrypting collection app), and clears supported values with null or an empty string. clearing a required text/email/password field returns pending_collection; clearing a required totp or passkey returns an error because the hosted form cannot collect it.\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") diff --git a/cmd/vaults_fill_test.go b/cmd/vaults_fill_test.go index 539ee57c..ed28d666 100644 --- a/cmd/vaults_fill_test.go +++ b/cmd/vaults_fill_test.go @@ -102,6 +102,15 @@ func TestVaultFillParamsValidation(t *testing.T) { } } +func TestVaultFillHelpMarksTimeoutDeprecated(t *testing.T) { + cmd, _, err := newVaultsCommand().Find([]string{"items", "invoke"}) + require.NoError(t, err) + assert.Contains(t, cmd.Long, "deprecated timeout_ms") + assert.Contains(t, cmd.Long, "accepted for compatibility but ignored") + assert.Contains(t, cmd.Long, "adapts typing pace to value length") + assert.NotContains(t, cmd.Long, "timeout_ms is 1-30000 (default 10000)") +} + func TestVaultFillBindingsSerialization(t *testing.T) { fields := make([]vaultFillField, 0) for _, field := range strings.Fields("number cvc exp_month exp_year billing_name billing_line1 billing_line2 billing_city billing_state billing_postal_code billing_country") { diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index 31b3d125..538e616e 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -70,7 +70,7 @@ type LinkCardSpec = { provider: "link"; wallet: string; // wallet item key payment_method_id: string; // from wallets payment-methods - amount: number; // integer minor units; 1..500000; a virtual card allows at most 50000 + amount: number; // integer minor units; 1..50000; with recurring, the per-period spending limit currency: string; // three letters merchant_name: string; // 1..255 characters merchant_url: string; // uri; a virtual card authorized without a checkout fills only this origin @@ -78,6 +78,13 @@ type LinkCardSpec = { line_items?: LinkLineItem[]; totals?: LinkTotal[]; metadata?: Record; + recurring?: LinkRecurring; +}; + +// recurring terms cannot change after authorization. interval_count defaults to 1. +type LinkRecurring = { + interval: "day" | "week" | "month" | "year"; + interval_count?: number; // integer >= 1; e.g. 2 with week means every two weeks }; type AgentCardCardSpec = { @@ -94,11 +101,13 @@ type AgentCardCardSpec = { // one-time code for fill on merchant_url's origin until expires_at. Mastercard purchases // need no hosted approval. a Visa purchase returns a spend_approval action: the cardholder // approves it with a Visa passkey (the link expires after 30 minutes) before the code is -// issued. updates are not supported. +// issued. when the card network cannot issue an agentic token, spend_approval instead +// collects a fresh security code; after collection, fill before expires_at, normally within +// five minutes. an expired purchase requires a new card. updates are not supported. type KernelCardSpec = { provider: "kernel"; wallet: string; // KERNEL wallet item key - amount: number; // integer minor units; 1..50000 + amount: number; // integer minor units; 1..1000000 currency: string; // iso 4217 three letters merchant_name: string; // 1..255 characters merchant_url: string; // https merchant checkout url; fill is locked to its origin @@ -125,6 +134,13 @@ type LinkTotal = { amount: number; // integer minor units }; +for a recurring Link card, amount is the spending limit for each period. the merchant +schedules later charges, and the card stays ready after each charge until it expires or is +deleted; do not create a new card each period. deleting the card, its wallet, or its vault +first revokes the Link credential so later charges decline. if deletion returns 409 +provider_revocation_pending, retry deletion. deletion does not cancel the merchant subscription. recurring +terms cannot be changed after authorization. + kernel cards cannot be updated. ` + kernelReplacementHelp + `. in recovery_required, ` + vaultRecoveryHelp + ` invoke authorize only when advertised. for Visa, present the returned diff --git a/cmd/vaults_link.go b/cmd/vaults_link.go index 6d7f65ef..45fcc19e 100644 --- a/cmd/vaults_link.go +++ b/cmd/vaults_link.go @@ -39,10 +39,13 @@ checkout is open in exactly one tab at page_url, then authorize again. if checko keeps failing, authorize without --browser-id and --page-url to issue a virtual card that fills any page on the merchant_url origin. once a card is authorized at a checkout, cards update returns 409: delete the card and create a new one. if authorization is declined or expires, -read status_reason, then delete the card and create a new one. if the amount exceeds the -50000 minor-unit virtual card limit on a checkout without Link Pay Token support, delete the -card and tell the user. if the result of submitting payment is unclear, check the merchant's -order confirmation or order history before paying again.` +read status_reason, then delete the card and create a new one. if the result of submitting +payment is unclear, check the merchant's order confirmation or order history before paying again. +for a recurring card, amount is the limit for each declared period. the merchant schedules +later charges and the same card remains ready until it expires or is deleted; do not create a +new card each period. deleting the card, wallet, or vault revokes the Link credential so later +charges decline, but does not cancel the merchant subscription. if deletion returns 409 +provider_revocation_pending, retry deletion.` const linkAuthorizeBrowserGuidance = "no payment request was made. make sure the checkout is open in exactly one tab at page_url in a browser with this vault attached, then authorize again. if checkout inspection keeps failing, authorize without --browser-id and --page-url to issue a virtual card that fills any page on the merchant_url origin" @@ -65,6 +68,10 @@ func parseVaultAuthorizeCheckout(cmd *cobra.Command, operation string) (*kernel. return nil, nil } if operation != "authorize" { + if isPasskeyOperation(operation) { + // Passkey flags are validated and converted by parseVaultPasskeyOperationFlags. + return nil, nil + } if vaultOperationTakesParams(operation) { return nil, fmt.Errorf("--browser-id and --page-url are only supported for authorize; pass %s inputs with --params or --spec-file", operation) } diff --git a/cmd/vaults_link_test.go b/cmd/vaults_link_test.go index 91390f67..ab77cbfe 100644 --- a/cmd/vaults_link_test.go +++ b/cmd/vaults_link_test.go @@ -47,8 +47,10 @@ func TestLinkCardFlowHelpOrder(t *testing.T) { "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", "cards update returns 409: delete the card and create a new one", - "50000 minor-unit virtual card limit", "check the merchant's order confirmation or order history before paying again", + "for a recurring card, amount is the limit for each declared period", + "do not create a new card each period", + "does not cancel the merchant subscription", "failed or unknown fills are safe to retry, but only after checking the page and not in an automatic loop", } { assert.Contains(t, flow, want) diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go index 9650e261..96f4e2c2 100644 --- a/cmd/vaults_operation_params.go +++ b/cmd/vaults_operation_params.go @@ -17,7 +17,8 @@ type vaultOperationParams struct { WebMCP *kernel.WebmcpInvokeVaultItemOperationRequestParam Checkout *kernel.VaultCheckoutContextParam ConfirmTransaction *vaultConfirmTransactionParams - // OnePassword is a complete 1pw_* request body; Invoke supplies the vault. + // Passkey and OnePassword are complete operation request bodies; Invoke supplies the vault. + Passkey *kernel.VaultItemPerformOperationParams OnePassword *kernel.VaultItemPerformOperationParams } @@ -93,6 +94,12 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( if len(raw) > 128*1024 { return nil, fmt.Errorf("operation parameters exceed 128 KiB") } + if isPasskeyOperation(operation) { + if paramsSet { + return nil, fmt.Errorf("%s uses --browser-id and optional --field, not --params or --spec-file", operation) + } + return nil, nil + } if isOnePasswordOperation(operation) { if operation == "1pw_recover" { if paramsSet { @@ -145,7 +152,7 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( } if operation != "fill" { if paramsSet { - return nil, fmt.Errorf("--params is only supported for fill, webmcp_invoke, prepare_checkout, confirm_transaction, and 1Password operations; authorize takes --browser-id and --page-url, and restart_verification takes no parameters") + return nil, fmt.Errorf("--params is only supported for fill, webmcp_invoke, prepare_checkout, confirm_transaction, and 1Password operations; authorize takes --browser-id and --page-url, passkey operations take --browser-id and optional --field, and restart_verification takes no parameters") } return nil, nil } diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 18f3eff6..d05c23fd 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -36,12 +36,32 @@ var vaultMethodFields = vaultOutputFields{ "display": vaultFieldsOf("label brand last4"), "capabilities": {"single_use_card": vaultFieldsOf("eligible reasons")}, } +var vaultLinkUserInfoFields = vaultOutputFields{ + "id": nil, "email": nil, "name": nil, "first_name": nil, "last_name": nil, "phone": nil, + "address": vaultFieldsOf("line1 line2 city state postal_code country"), + "eligible_for_balance": nil, + "default_spend_request_currency": nil, + "agent_wallet_spend_limits": { + "per_transaction": vaultFieldsOf("limit"), + "daily": vaultFieldsOf("limit used remaining"), + "thirty_day": vaultFieldsOf("limit used remaining"), + }, + "agent_wallet_step_up": vaultFieldsOf("status action_url"), +} +var vaultLinkShippingAddressFields = vaultOutputFields{ + "id": nil, "is_default": nil, "nickname": nil, + "address": vaultFieldsOf("name line_1 line_2 locality dependent_locality administrative_area postal_code sorting_code country_code"), +} var vaultItemFields = vaultOutputFields{ "id": nil, "key": nil, "type": nil, "version": nil, "created_at": nil, "updated_at": nil, "expires_at": nil, "description": nil, "available_operations": vaultOperationFields, "available_expansions": vaultOperationFields, "action": vaultFieldsOf("name url expires_at instructions"), - "expanded": {"payment_methods": vaultMethodFields}, + "expanded": { + "payment_methods": vaultMethodFields, + "link_user_info": vaultLinkUserInfoFields, + "link_shipping_addresses": vaultLinkShippingAddressFields, + }, "spec": { "provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil, "checkout_origin": nil, "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, "merchant_country": nil, "merchant_category": nil, "merchant_category_code": nil, @@ -51,6 +71,7 @@ var vaultItemFields = vaultOutputFields{ "provider_config": vaultFieldsOf("id name"), "authorization": {"method": nil, "client": {"type": nil, "provider_config": vaultFieldsOf("id name")}}, "totals": vaultTotalFields, + "recurring": vaultFieldsOf("interval interval_count"), "line_items": { "name": nil, "quantity": nil, "unit_amount": nil, "description": nil, "sku": nil, "url": nil, "image_url": nil, "product_url": nil, "totals": vaultTotalFields, @@ -121,7 +142,8 @@ func filterVaultJSON(raw json.RawMessage, fields vaultOutputFields) (json.RawMes continue } if value, ok := object[key]; ok { - if key == "url" || key == "approval_url" || key == "merchant_url" || key == "merchant_origin" || key == "image_url" || key == "product_url" || key == "website" { + isNullActionURL := key == "action_url" && bytes.Equal(bytes.TrimSpace(value), []byte("null")) + if (key == "url" || key == "approval_url" || key == "action_url" || key == "merchant_url" || key == "merchant_origin" || key == "image_url" || key == "product_url" || key == "website") && !isNullActionURL { var address string if json.Unmarshal(value, &address) != nil { continue @@ -338,6 +360,12 @@ func printVaultOperationHints(item *kernel.VaultItemUnion, vault, key, project s if vaultOperationTakesParams(op.Type) { command += " --params ''" } + if isPasskeyOperation(op.Type) { + command += " --browser-id ''" + if op.Type != "disarm_passkey" { + command += " --field ''" + } + } if op.Type == "authorize" && item.Type == "card" && item.Spec.Provider == "link" && item.State.Status == "requested" { command += " --browser-id '' --page-url ''" } @@ -424,6 +452,16 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { rows = append(rows, []string{"wallet key", item.Spec.Wallet}, []string{"merchant", merchant}, []string{"amount (minor units)", fmt.Sprintf("%d %s", item.Spec.Amount, item.Spec.Currency)}) if item.Spec.Provider == "link" { rows = append(rows, []string{"payment method id", item.Spec.PaymentMethodID}) + if item.Spec.JSON.Recurring.Valid() { + intervalCount := "1 (default)" + if item.Spec.Recurring.JSON.IntervalCount.Valid() { + intervalCount = fmt.Sprint(item.Spec.Recurring.IntervalCount) + } + rows = append(rows, + []string{"recurring interval", item.Spec.Recurring.Interval}, + []string{"recurring interval count", intervalCount}, + ) + } } if item.Spec.Provider == "agentcard" && item.Spec.CheckoutOrigin != "" { rows = append(rows, []string{"checkout origin", item.Spec.CheckoutOrigin}) @@ -532,14 +570,14 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction 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.") + pterm.Info.Println("ready means required fields are populated, not that login succeeded. fill only when advertised; fill does not submit the form. passkey fields are never filled: use register_passkey, arm_passkey, and disarm_passkey only when advertised.") return } if item.Type == "card" { if item.Spec.Provider == "kernel" { switch item.State.Status { case "requested", "pending_authorization", "ready": - pterm.Info.Println("Visa may require the returned spend_approval action; share it privately, never open it for the cardholder. ready means a one-time code is available, not that the merchant charged the card. fill and submit checkout before expires_at.") + pterm.Info.Println("Visa may require passkey approval through spend_approval; an alias-mode card may instead require fresh security-code collection. share the link privately and never open it for the cardholder; an unused link expires after 30 minutes. alias-mode cards normally expire five minutes after collection. ready means card details are available, not that the merchant charged the card; fill and submit before expires_at.") } switch item.State.Status { case "ready", "declined", "expired", "consumed": @@ -567,6 +605,9 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction pterm.Info.Println(kernelTransactionHelp + ". kernel cards have no stored billing fields; billing_* returns field_unavailable. enter billing details on the merchant page.") case "link": 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 card and create a new one.") + if item.Spec.JSON.Recurring.Valid() { + pterm.Info.Println("this recurring card remains ready for merchant-scheduled charges until it expires or is deleted; do not create a new card each period. deleting the card, wallet, or vault revokes the Link credential but does not cancel the merchant subscription. if deletion returns 409 provider_revocation_pending, retry deletion.") + } default: pterm.Info.Println("inspect items events for payment outcomes. never retry automatically; if recovery permits abandonment, delete the card only after explicit user confirmation before creating a replacement.") } @@ -585,6 +626,12 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction if item.Expanded.JSON.PaymentMethods.Valid() { printVaultPaymentMethods(item.Spec.Provider, item.Expanded.PaymentMethods) } + if item.Expanded.JSON.LinkUserInfo.Valid() { + printVaultLinkUserInfo(item.Expanded.LinkUserInfo) + } + if item.Expanded.JSON.LinkShippingAddresses.Valid() { + printVaultLinkShippingAddresses(item.Expanded.LinkShippingAddresses) + } if actions.RequiredAction != "" { pterm.Info.Println("complete the returned action with the provider; never pass card data or OAuth codes to the cli. observe with items get --wait 60.") } @@ -612,6 +659,124 @@ func printVaultPaymentMethods(provider string, methods []kernel.VaultPaymentMeth } } +func vaultExpandedString(value, raw string) string { + if raw == "" || raw == "null" { + return "—" + } + return value +} + +func vaultExpandedLimit(value int64, raw string) string { + if raw == "null" { + return "unlimited" + } + if raw == "" { + return "—" + } + return fmt.Sprint(value) +} + +func compactVaultAddress(parts ...string) string { + values := make([]string, 0, len(parts)) + for _, part := range parts { + if strings.TrimSpace(part) != "" { + values = append(values, part) + } + } + return strings.Join(values, ", ") +} + +func printVaultLinkUserInfo(info kernel.LinkUserInfo) { + rows := pterm.TableData{{"Link user property", "value"}} + addString := func(label, value, raw string) { + if raw != "" { + rows = append(rows, []string{label, vaultExpandedString(value, raw)}) + } + } + addString("id", info.ID, info.JSON.ID.Raw()) + addString("name", info.Name, info.JSON.Name.Raw()) + addString("first name", info.FirstName, info.JSON.FirstName.Raw()) + addString("last name", info.LastName, info.JSON.LastName.Raw()) + addString("email", info.Email, info.JSON.Email.Raw()) + addString("phone", info.Phone, info.JSON.Phone.Raw()) + if raw := info.JSON.Address.Raw(); raw != "" { + address := "—" + if raw != "null" { + a := info.Address + address = compactVaultAddress(a.Line1, a.Line2, a.City, a.State, a.PostalCode, a.Country) + if address == "" { + address = "—" + } + } + rows = append(rows, []string{"address", address}) + } + if raw := info.JSON.EligibleForBalance.Raw(); raw != "" { + value := "—" + if raw != "null" { + value = fmt.Sprint(info.EligibleForBalance) + } + rows = append(rows, []string{"eligible for balance", value}) + } + addString("default spend currency", info.DefaultSpendRequestCurrency, info.JSON.DefaultSpendRequestCurrency.Raw()) + if info.JSON.AgentWalletSpendLimits.Valid() { + limits := info.AgentWalletSpendLimits + if limits.JSON.PerTransaction.Raw() != "" { + rows = append(rows, []string{"per-transaction limit", vaultExpandedLimit(limits.PerTransaction.Limit, limits.PerTransaction.JSON.Limit.Raw())}) + } + if limits.JSON.Daily.Raw() != "" { + rows = append(rows, + []string{"daily limit", vaultExpandedLimit(limits.Daily.Limit, limits.Daily.JSON.Limit.Raw())}, + []string{"daily used", vaultExpandedLimit(limits.Daily.Used, limits.Daily.JSON.Used.Raw())}, + []string{"daily remaining", vaultExpandedLimit(limits.Daily.Remaining, limits.Daily.JSON.Remaining.Raw())}, + ) + } + if limits.JSON.ThirtyDay.Raw() != "" { + rows = append(rows, + []string{"30-day limit", vaultExpandedLimit(limits.ThirtyDay.Limit, limits.ThirtyDay.JSON.Limit.Raw())}, + []string{"30-day used", vaultExpandedLimit(limits.ThirtyDay.Used, limits.ThirtyDay.JSON.Used.Raw())}, + []string{"30-day remaining", vaultExpandedLimit(limits.ThirtyDay.Remaining, limits.ThirtyDay.JSON.Remaining.Raw())}, + ) + } + } + if info.JSON.AgentWalletStepUp.Valid() { + addString("verification status", info.AgentWalletStepUp.Status, info.AgentWalletStepUp.JSON.Status.Raw()) + } + if len(rows) > 1 { + PrintTableNoPad(rows, true) + } + if info.JSON.AgentWalletStepUp.Valid() && info.AgentWalletStepUp.JSON.ActionURL.Valid() && vaultDisplayURL(info.AgentWalletStepUp.ActionURL) { + pterm.Printf("Link verification action url:\n%s\n", info.AgentWalletStepUp.ActionURL) + } + pterm.Info.Println("Link user info is live provider data and is not stored in the vault item.") +} + +func printVaultLinkShippingAddresses(addresses []kernel.LinkShippingAddress) { + if len(addresses) == 0 { + pterm.Info.Println("no Link shipping addresses returned") + return + } + rows := pterm.TableData{{"shipping address id", "nickname", "default", "recipient and address"}} + for _, address := range addresses { + isDefault := "—" + if address.JSON.IsDefault.Valid() { + isDefault = fmt.Sprint(address.IsDefault) + } + details := address.Address + formatted := compactVaultAddress(details.Name, details.Line1, details.Line2, details.Locality, details.DependentLocality, details.AdministrativeArea, details.PostalCode, details.SortingCode, details.CountryCode) + if formatted == "" { + formatted = "—" + } + rows = append(rows, []string{ + address.ID, + vaultExpandedString(address.Nickname, address.JSON.Nickname.Raw()), + isDefault, + formatted, + }) + } + PrintTableNoPad(rows, true) + pterm.Info.Println("Link shipping addresses are live provider data and are not stored in the vault item.") +} + func printVaultEvents(events []kernel.VaultItemEvent, data []vaultJSON) { rows := pterm.TableData{{"event id", "time", "name", "browser id", "outcome data"}} for i, event := range events { diff --git a/cmd/vaults_output_test.go b/cmd/vaults_output_test.go index c2053f4e..f09c2ffe 100644 --- a/cmd/vaults_output_test.go +++ b/cmd/vaults_output_test.go @@ -177,6 +177,43 @@ func TestVaultOutputPaymentMethodsAdvisoryUnknownVsFalse(t *testing.T) { assert.Contains(t, out, `"capabilities": {}`) } +func TestVaultOutputLinkRecurringTerms(t *testing.T) { + body := strings.Replace(readyLinkCardFixture, `"merchant_url":"https://shop.example"`, `"merchant_url":"https://shop.example","recurring":{"interval":"week","interval_count":2,"provider_secret":"SECRET_RECURRING"}`, 1) + var item kernel.VaultItemUnion + require.NoError(t, json.Unmarshal([]byte(body), &item)) + buf := capturePtermOutput(t) + require.NoError(t, printVaultItem(&item, "")) + for _, text := range []string{"recurring interval", "week", "recurring interval count", "2", "merchant-scheduled charges", "do not create a new card each period", "does not cancel the merchant subscription"} { + assert.Contains(t, buf.String(), text) + } + assert.NotContains(t, buf.String(), "SECRET_RECURRING") + out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) + assert.Contains(t, out, `"recurring"`) + assert.Contains(t, out, `"interval": "week"`) + assert.Contains(t, out, `"interval_count": 2`) + assert.NotContains(t, out, "SECRET_RECURRING") +} + +func TestVaultOutputLinkLiveExpansions(t *testing.T) { + body := strings.TrimSuffix(connectedWalletFixture, "}") + `,"expanded":{ + "link_user_info":{"id":"usr_1","email":"jane@example.com","name":"Jane Doe","first_name":"Jane","last_name":"Doe","phone":null,"address":{"line1":"510 Townsend St","line2":null,"city":"San Francisco","state":"CA","postal_code":"94103","country":"US","provider_secret":"SECRET_ADDRESS"},"eligible_for_balance":true,"default_spend_request_currency":"usd","agent_wallet_spend_limits":{"per_transaction":{"limit":50000},"daily":{"limit":500000,"used":120000,"remaining":380000},"thirty_day":{"limit":null,"used":600000,"remaining":null}},"agent_wallet_step_up":{"status":"identity_verification","action_url":"https://link.example/verify?code=SECRET_STEPUP"},"provider_secret":"SECRET_USER"}, + "link_shipping_addresses":[{"id":"sa_1","is_default":true,"nickname":null,"address":{"name":"Jane Doe","line_1":"510 Townsend St","line_2":null,"locality":"San Francisco","dependent_locality":null,"administrative_area":"CA","postal_code":"94103","sorting_code":null,"country_code":"US","provider_secret":"SECRET_SHIPPING"},"provider_secret":"SECRET_ENTRY"}] + }}` + var item kernel.VaultItemUnion + require.NoError(t, json.Unmarshal([]byte(body), &item)) + buf := capturePtermOutput(t) + require.NoError(t, printVaultItem(&item, "")) + for _, text := range []string{"Link user property", "Jane Doe", "jane@example.com", "eligible for balance", "per-transaction limit", "50000", "30-day limit", "unlimited", "shipping address id", "sa_1", "510 Townsend St", "live provider data"} { + assert.Contains(t, buf.String(), text) + } + assert.NotContains(t, buf.String(), "SECRET") + out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) + for _, text := range []string{`"link_user_info"`, `"link_shipping_addresses"`, `"phone": null`, `"nickname": null`, `"limit": null`} { + assert.Contains(t, out, text) + } + assert.NotContains(t, out, "SECRET") +} + func TestVaultActionOutputNoInventedURLs(t *testing.T) { for _, action := range []struct{ name, url string }{ {"link_oauth", "https://provider.example/auth?state=state&code_challenge=challenge"}, diff --git a/cmd/vaults_passkeys.go b/cmd/vaults_passkeys.go new file mode 100644 index 00000000..de6fdd1d --- /dev/null +++ b/cmd/vaults_passkeys.go @@ -0,0 +1,147 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strings" + + kernel "github.com/kernel/kernel-go-sdk" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +const vaultPasskeyOperationHelp = `passkey operations apply only when advertised by a KERNEL-hosted credential item. +they require --browser-id with a browser session id, not a reusable browser name. the +browser must have the item's vault attached. --field selects one passkey field; arm and +register require it when more than one eligible field exists, while disarm omits it to +affect every passkey field. the positional operation supplies type. + +arm_passkey lets that browser answer matching navigator.credentials.get requests with a +stored passkey. KERNEL signs on its servers, so the private key never enters the browser. +it returns immediately; there is no time limit. it stays armed until disarm_passkey, the +browser ends, the vault is detached, or the item or field is deleted. arming another +passkey for the same relying party replaces the earlier one. inspect the page to confirm +sign-in. if a new tab asks for a passkey in its first scripts before the handler attaches, +reload it. while armed, any script on the relying party's pages can obtain a sign-in +without user interaction; disarm when the task is done. signatures use counter 0 and +synced-passkey backup flags. + +register_passkey makes the next navigator.credentials.create request in that browser create +a passkey and store it in the selected empty passkey field before returning it to the page. +only ES256 passkeys are created. declare an optional passkey field first. the operation +returns immediately; there is no time limit, and a later register_passkey replaces the +browser's prior pending registration. drive the site's add-passkey flow yourself, then +confirm the field has_value with items get or inspect items events for +credential_passkey_registered; use arm_passkey to test sign-in. + +disarm_passkey removes this item's armed passkeys and pending registration from the browser. +with --field it affects only that field; without --field it affects every passkey field on +the item. pages already loaded keep KERNEL's WebAuthn handler but use the browser's own +behavior.` + +func isPasskeyOperation(operation string) bool { + switch operation { + case "arm_passkey", "register_passkey", "disarm_passkey": + return true + default: + return false + } +} + +func parseVaultPasskeyOperationFlags(cmd *cobra.Command, operation string) (*kernel.VaultItemPerformOperationParams, error) { + fieldSet := cmd.Flags().Changed("field") + if !isPasskeyOperation(operation) { + if fieldSet { + return nil, fmt.Errorf("--field is only supported for arm_passkey, register_passkey, and disarm_passkey") + } + return nil, nil + } + if cmd.Flags().Changed("params") || cmd.Flags().Changed("spec-file") { + return nil, fmt.Errorf("%s uses --browser-id and optional --field, not --params or --spec-file", operation) + } + if cmd.Flags().Changed("page-url") { + return nil, fmt.Errorf("--page-url is only supported for authorize; %s was not invoked", operation) + } + if !cmd.Flags().Changed("browser-id") { + return nil, fmt.Errorf("%s requires --browser-id with a browser session id, not a name", operation) + } + browserID, _ := cmd.Flags().GetString("browser-id") + if strings.TrimSpace(browserID) == "" { + return nil, fmt.Errorf("--browser-id must be a non-empty browser session id, not a name") + } + field, _ := cmd.Flags().GetString("field") + if fieldSet && (strings.TrimSpace(field) == "" || len(field) > 64) { + return nil, fmt.Errorf("--field must be a non-empty passkey field name of at most 64 bytes") + } + + request := &kernel.VaultItemPerformOperationParams{} + switch operation { + case "arm_passkey": + body := &kernel.ArmPasskeyVaultItemOperationRequestParam{ + BrowserID: browserID, + Type: kernel.ArmPasskeyVaultItemOperationRequestTypeArmPasskey, + } + if fieldSet { + body.Field = kernel.Opt(field) + } + request.OfArmPasskey = body + case "register_passkey": + body := &kernel.RegisterPasskeyVaultItemOperationRequestParam{ + BrowserID: browserID, + Type: kernel.RegisterPasskeyVaultItemOperationRequestTypeRegisterPasskey, + } + if fieldSet { + body.Field = kernel.Opt(field) + } + request.OfRegisterPasskey = body + case "disarm_passkey": + body := &kernel.DisarmPasskeyVaultItemOperationRequestParam{ + BrowserID: browserID, + Type: kernel.DisarmPasskeyVaultItemOperationRequestTypeDisarmPasskey, + } + if fieldSet { + body.Field = kernel.Opt(field) + } + request.OfDisarmPasskey = body + } + return request, nil +} + +type vaultPasskeyOperationResult struct { + Type string `json:"type"` + Status string `json:"status"` + RpID string `json:"rp_id,omitempty"` +} + +var vaultPasskeyOperationResultFields = vaultFieldsOf("type status rp_id") + +func printVaultPasskeyOperationResult(response *kernel.VaultItemOperationResponseUnion, operation, output string) error { + if response == nil { + return fmt.Errorf("empty %s result; inspect the item and browser before invoking another passkey operation", operation) + } + safe, err := filterVaultJSON(json.RawMessage(response.RawJSON()), vaultPasskeyOperationResultFields) + if err != nil { + return fmt.Errorf("invalid %s result; inspect the item and browser before invoking another passkey operation", operation) + } + var result vaultPasskeyOperationResult + if json.Unmarshal(safe, &result) != nil || result.Type != operation || (result.Status != "armed" && result.Status != "disarmed") { + return fmt.Errorf("invalid %s result; inspect the item and browser before invoking another passkey operation", operation) + } + if output == "json" { + return printVaultJSON(result) + } + rows := pterm.TableData{{"property", "value"}, {"operation", result.Type}, {"status", result.Status}} + if result.RpID != "" { + rows = append(rows, []string{"relying party", result.RpID}) + } + PrintTableNoPad(rows, true) + switch operation { + case "arm_passkey": + pterm.Info.Println("armed means the browser will answer matching passkey requests; it does not mean a page has asked yet. inspect the page, and disarm when the task is done.") + case "register_passkey": + pterm.Info.Println("complete the website's add-passkey flow, then confirm the field has_value with items get or inspect items events. only ES256 passkeys are created.") + case "disarm_passkey": + pterm.Info.Println("the browser no longer uses the selected passkey field or fields from this item; already-loaded pages use the browser's own behavior.") + } + return nil +} diff --git a/cmd/vaults_passkeys_test.go b/cmd/vaults_passkeys_test.go new file mode 100644 index 00000000..11cc291b --- /dev/null +++ b/cmd/vaults_passkeys_test.go @@ -0,0 +1,166 @@ +package cmd + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const passkeyCredentialFixture = `{"id":"credential-1","key":"login","type":"credential","version":1,"spec":{"provider":"kernel","description":"Example","fields":[{"name":"passkey","type":"passkey","required":false,"sensitive":true}]},"state":{"provider":"kernel","status":"ready","fields":{"passkey":{"has_value":true,"type":"passkey"}}},"available_operations":[{"type":"arm_passkey","description":"Arm the stored passkey."}],"available_expansions":[]}` + +func passkeyFixture(operation string) string { + return strings.ReplaceAll(passkeyCredentialFixture, `"type":"arm_passkey","description":"Arm the stored passkey."`, `"type":"`+operation+`","description":"Use the advertised passkey operation."`) +} + +func TestVaultPasskeyOperationsUseGeneratedSDKVariants(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + tests := []struct { + operation string + flags []string + expectedBody string + response string + expectedJSON string + }{ + { + operation: "arm_passkey", + flags: []string{"--browser-id", "browser-session-id", "--field", "passkey"}, + expectedBody: `{"type":"arm_passkey","browser_id":"browser-session-id","field":"passkey"}`, + response: `{"type":"arm_passkey","status":"armed","rp_id":"example.com","opaque":"discard-me"}`, + expectedJSON: `{"type":"arm_passkey","status":"armed","rp_id":"example.com"}`, + }, + { + operation: "register_passkey", + flags: []string{"--browser-id", "browser-session-id", "--field", "passkey"}, + expectedBody: `{"type":"register_passkey","browser_id":"browser-session-id","field":"passkey"}`, + response: `{"type":"register_passkey","status":"armed","opaque":"discard-me"}`, + expectedJSON: `{"type":"register_passkey","status":"armed"}`, + }, + { + operation: "disarm_passkey", + flags: []string{"--browser-id", "browser-session-id"}, + expectedBody: `{"type":"disarm_passkey","browser_id":"browser-session-id"}`, + response: `{"type":"disarm_passkey","status":"disarmed","opaque":"discard-me"}`, + expectedJSON: `{"type":"disarm_passkey","status":"disarmed"}`, + }, + } + for _, tt := range tests { + t.Run(tt.operation, func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + switch calls { + case 1: + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/vaults/user-vault/items/login", r.URL.Path) + _, _ = io.WriteString(w, passkeyFixture(tt.operation)) + case 2: + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/vaults/user-vault/items/login/operations", r.URL.Path) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, tt.expectedBody, string(body)) + _, _ = io.WriteString(w, tt.response) + default: + t.Fatalf("unexpected API call %d", calls) + } + }) + args := []string{"vaults", "items", "invoke", "user-vault", "login", tt.operation} + args = append(args, tt.flags...) + args = append(args, "-o", "json") + out, human, err := executeVaultCommand(t, client, args...) + require.NoError(t, err) + assert.Equal(t, 2, calls) + assert.JSONEq(t, tt.expectedJSON, out) + assert.Empty(t, human) + assert.NotContains(t, out, "discard-me") + }) + } +} + +func TestVaultPasskeyOperationFlagsValidateBeforeAPI(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("invalid passkey operation reached API") + }) + tests := []struct { + name string + args []string + want string + }{ + {"missing browser", []string{"arm_passkey"}, "requires --browser-id with a browser session id, not a name"}, + {"empty browser", []string{"arm_passkey", "--browser-id", ""}, "non-empty browser session id"}, + {"empty field", []string{"arm_passkey", "--browser-id", "browser-id", "--field", ""}, "non-empty passkey field name"}, + {"long field", []string{"register_passkey", "--browser-id", "browser-id", "--field", strings.Repeat("x", 65)}, "at most 64 bytes"}, + {"page url", []string{"disarm_passkey", "--browser-id", "browser-id", "--page-url", "https://example.com"}, "--page-url is only supported for authorize"}, + {"params", []string{"arm_passkey", "--browser-id", "browser-id", "--params", `{"field":"passkey"}`}, "not --params or --spec-file"}, + {"open", []string{"arm_passkey", "--browser-id", "browser-id", "--open"}, "--open is only supported"}, + {"field on authorize", []string{"authorize", "--field", "passkey"}, "--field is only supported"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args := []string{"vaults", "items", "invoke", "user-vault", "login"} + args = append(args, tt.args...) + _, _, err := executeVaultCommand(t, client, args...) + require.ErrorContains(t, err, tt.want) + }) + } +} + +func TestVaultPasskeyOperationHumanOutputAndHints(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + fixture := strings.ReplaceAll(passkeyCredentialFixture, + `[{"type":"arm_passkey","description":"Arm the stored passkey."}]`, + `[{"type":"arm_passkey","description":"Arm it."},{"type":"register_passkey","description":"Register it."},{"type":"disarm_passkey","description":"Disarm it."}]`) + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost { + _, _ = io.WriteString(w, `{"type":"arm_passkey","status":"armed","rp_id":"example.com"}`) + return + } + _, _ = io.WriteString(w, fixture) + }) + _, human, err := executeVaultCommand(t, client, "vaults", "items", "get", "user-vault", "login") + require.NoError(t, err) + assert.Contains(t, human, "--browser-id '' --field '' -- user-vault login arm_passkey") + assert.Contains(t, human, "--browser-id '' --field '' -- user-vault login register_passkey") + assert.Contains(t, human, "--browser-id '' -- user-vault login disarm_passkey") + + _, human, err = executeVaultCommand(t, client, "vaults", "items", "invoke", "user-vault", "login", "arm_passkey", "--browser-id", "browser-session-id") + require.NoError(t, err) + assert.Contains(t, human, "relying party") + assert.Contains(t, human, "example.com") + assert.Contains(t, human, "does not mean a page has asked yet") + assert.Equal(t, 3, calls) +} + +func TestVaultPasskeyHelpMatchesSpec(t *testing.T) { + cmd, _, err := newVaultsCommand().Find([]string{"items", "invoke"}) + require.NoError(t, err) + require.NotNil(t, cmd.Flags().Lookup("field")) + assert.Contains(t, cmd.Flags().Lookup("browser-id").Usage, "session id, not a reusable name") + assert.Contains(t, cmd.Flags().Lookup("page-url").Usage, "checkout url") + assert.Contains(t, cmd.Flags().Lookup("field").Usage, "disarm omits it to affect every field") + for _, text := range []string{ + "browser session id, not a reusable browser name", + "private key never enters the browser", + "there is no time limit", + "reload it", + "without user interaction", + "only ES256 passkeys are created", + "without --field it affects every passkey field", + } { + assert.Contains(t, cmd.Long, text) + } + credentials := newVaultCredentialsCommand() + assert.Contains(t, credentials.Long, "totp, and passkey") + assert.Contains(t, credentials.Long, "FIDO Credential Exchange Format") + assert.Contains(t, credentials.Long, "PKCS#8 ECDSA P-256") + assert.Contains(t, credentials.Long, "passkeys are never filled") +} diff --git a/cmd/vaults_spec_test.go b/cmd/vaults_spec_test.go index 4a9702b6..ef8a2ab1 100644 --- a/cmd/vaults_spec_test.go +++ b/cmd/vaults_spec_test.go @@ -20,6 +20,7 @@ func TestVaultRawSpecForwarding(t *testing.T) { `{"custom_option":false,"amount":0,"currency":"USD","metadata":null}`, `{"checkout_origin":"https://shop.example.com","amount":1234}`, `{"line_items":[{"name":"Item","quantity":2,"unit_amount":100,"totals":[{"type":"tax","display_text":"Tax","amount":10}]}],"totals":[],"metadata":{"reference":"order-1"}}`, + `{"amount":5000,"recurring":{"interval":"week","interval_count":2}}`, } { t.Run(path+"/"+provider+"/"+raw, func(t *testing.T) { var expected map[string]json.RawMessage @@ -91,10 +92,10 @@ func TestVaultSpecHelpAndFlags(t *testing.T) { assert.NotContains(t, cmd.Long, "sandbox/live") assert.NotContains(t, cmd.Long, "expires_at?:") if strings.HasPrefix(path, "cards") { - for _, field := range []string{"merchant_name:", "merchant:", "line_items?:", "metadata?:", "checkout_origin?:", "type LinkLineItem", "type LinkTotal"} { + for _, field := range []string{"merchant_name:", "merchant:", "line_items?:", "metadata?:", "recurring?:", "type LinkRecurring", `interval: "day" | "week" | "month" | "year"`, "checkout_origin?:", "type LinkLineItem", "type LinkTotal"} { assert.Contains(t, cmd.Long, field) } - for _, guidance := range []string{"eligible", "autopilot may not apply", "user approval may", "does not guarantee payment success", "KERNEL does not validate this declaration against the page open in the browser.", "preparation merchant_origin instead", "card updates replace the whole spec", "omitting checkout_origin from an update removes"} { + for _, guidance := range []string{"eligible", "autopilot may not apply", "user approval may", "does not guarantee payment success", "KERNEL does not validate this declaration against the page open in the browser.", "preparation merchant_origin instead", "card updates replace the whole spec", "omitting checkout_origin from an update removes", "per-period spending limit", "do not create a new card each period", "does not cancel the merchant subscription", "1..1000000"} { assert.Contains(t, cmd.Long, guidance) } } else { diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go index 8b94f0c3..f2d640dd 100644 --- a/cmd/vaults_test.go +++ b/cmd/vaults_test.go @@ -494,6 +494,32 @@ func TestVaultGetWaitExpansionAndEvents(t *testing.T) { assert.NotContains(t, human, "SECRET") } +func TestVaultGetLinkLiveExpansions(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "project-test") + body := strings.TrimSuffix(connectedWalletFixture, "}") + `,"expanded":{ + "link_user_info":{"id":"usr_1","email":"jane@example.com","name":"Jane Doe","first_name":"Jane","last_name":"Doe","phone":null,"eligible_for_balance":true,"default_spend_request_currency":"usd","agent_wallet_spend_limits":{"per_transaction":{"limit":50000},"daily":{"limit":500000,"used":120000,"remaining":380000},"thirty_day":{"limit":null,"used":600000,"remaining":null}},"agent_wallet_step_up":{"status":"not_required","action_url":null}}, + "link_shipping_addresses":[{"id":"sa_1","is_default":true,"nickname":"Home","address":{"name":"Jane Doe","line_1":"510 Townsend St","line_2":null,"locality":"San Francisco","dependent_locality":null,"administrative_area":"CA","postal_code":"94103","sorting_code":null,"country_code":"US"}}] + }}` + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/vaults/checkout/items/wallet-1", r.URL.Path) + assert.Equal(t, "link_user_info,link_shipping_addresses", r.URL.Query().Get("expand")) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, body) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "wallet-1", "--expand", "link_user_info,link_shipping_addresses", "-o", "json") + require.NoError(t, err) + assert.Contains(t, out, `"link_user_info"`) + assert.Contains(t, out, `"link_shipping_addresses"`) + + cmd, _, err := newVaultsCommand().Find([]string{"items", "get"}) + require.NoError(t, err) + for _, value := range vaultItemExpansionTypes { + assert.Contains(t, cmd.Flags().Lookup("expand").Usage, value) + } + assert.Contains(t, cmd.Long, "live, non-persisted provider data") + assert.Contains(t, cmd.Long, "personal data") +} + func TestVaultDeleteResponses(t *testing.T) { t.Setenv("KERNEL_PROJECT", "project-test") for _, path := range []string{"vaults delete checkout --yes", "vaults items delete checkout order-1 --yes"} { diff --git a/go.mod b/go.mod index 9e8741d2..73f72506 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.129.0 + github.com/kernel/kernel-go-sdk v0.131.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 0269be1b..2b7257a1 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,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.129.0 h1:kU67s3LnMm1g9gbXZsA9FsAeeUYo04yHeqLzqGCjGOQ= -github.com/kernel/kernel-go-sdk v0.129.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.131.0 h1:9aYi3O5N8g/cNwAIevmL+lOy8yy6daz5EXciCPipLco= +github.com/kernel/kernel-go-sdk v0.131.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=