diff --git a/README.md b/README.md index 3c93a6aa..1da76b2a 100644 --- a/README.md +++ b/README.md @@ -275,13 +275,14 @@ Vault commands **collect user credentials and manage payment credentials; fill d #### User credentials Create a vault for the end user, attach it when creating a browser, then navigate to the -sensitive form. Define the observed fields without supplying values: +sensitive form. Define the observed fields in the website's natural top-to-bottom order; +the collection form renders this array order unchanged. Omit values for user collection: ```sh kernel vaults create --name user-vault kernel browsers create --vault user-vault kernel vaults credentials create user-vault login --spec-file - <<'JSON' -{"description":"Hacker News","fields":{"username":{"type":"text","required":true,"sensitive":false},"password":{"type":"password","required":true,"sensitive":true}}} +{"description":"Hacker News","fields":[{"name":"username","type":"text","required":true,"sensitive":false},{"name":"password","type":"password","required":true,"sensitive":true}]} JSON kernel vaults items get user-vault login --wait 60 -o json kernel vaults items invoke user-vault login fill --spec-file - <<'JSON' diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index a7e2b06a..d725e66c 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -59,7 +59,7 @@ Use wallet and card item types for credit cards and payment checkout instead. User credential flow: 1. Create a vault per end user and create a browser with --vault . -2. Navigate to a sensitive form and define its fields with credentials create --spec-file. +2. Navigate to a sensitive form and define its fields in natural top-to-bottom order with credentials create --spec-file; that array order controls the user-facing collection form. 3. Present the returned collection URL to the user. Poll items get --wait 60 for ready. 4. Use items invoke fill --spec-file with browser_id and field selectors. Use credentials update --version for edits, or items invoke collect to reopen the form. diff --git a/cmd/vaults_credential_guidance_test.go b/cmd/vaults_credential_guidance_test.go index 00142808..eb63830c 100644 --- a/cmd/vaults_credential_guidance_test.go +++ b/cmd/vaults_credential_guidance_test.go @@ -22,7 +22,7 @@ func TestCredentialHumanGuidance(t *testing.T) { args := []string{"vaults", "items", "get", "user", "login"} switch operation { case "create": - args = []string{"vaults", "credentials", "create", "user", "login", "--spec-file", credentialSpecFile(t, `{"fields":{"password":{"type":"password"}}}`)} + args = []string{"vaults", "credentials", "create", "user", "login", "--spec-file", credentialSpecFile(t, `{"fields":[{"name":"password","type":"password"}]}`)} case "collect": args = []string{"vaults", "items", "invoke", "user", "login", "collect"} } diff --git a/cmd/vaults_credential_steering_test.go b/cmd/vaults_credential_steering_test.go index e6613eaf..7f2e1667 100644 --- a/cmd/vaults_credential_steering_test.go +++ b/cmd/vaults_credential_steering_test.go @@ -30,7 +30,10 @@ func TestCredentialHelpSteering(t *testing.T) { assert.Contains(t, strings.Join(strings.Fields(cmd.Long), " "), "Use wallet and card item types for credit cards and payment checkout instead") assert.Contains(t, newVaultsCommand().Long, "Use wallet and card item types") assert.Contains(t, cmd.Long, "recognizable site name only") + assert.Contains(t, cmd.Long, "natural top-to-bottom order") + assert.Contains(t, cmd.Long, "collection form renders that order unchanged") assert.Contains(t, cmd.Long, "sensitive:false explicitly for ordinary usernames and email addresses") assert.Contains(t, cmd.Example, `"description":"Hacker News"`) + assert.Contains(t, cmd.Example, `"fields":[{"name":"username"`) assert.Contains(t, cmd.Example, `"sensitive":false`) } diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index 64f8f1b4..5c446523 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -21,8 +21,10 @@ card item types for credit cards and payment checkout instead. First create a vault for the end user and attach it with browsers create --vault. Use a protected JSON file or stdin, never secret values in shell arguments. -The spec contains description and fields keyed by name. Field types are text, -email, password, and totp; definitions accept required, sensitive, and value. +The spec contains description and 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. Field types are text, +email, password, and totp; definitions accept name, required, sensitive, and value. 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. @@ -66,7 +68,7 @@ func newVaultCredentialsCommand() *cobra.Command { cmd.Example = " kernel vaults credentials update user-vault login --version 2 --spec-file changes.json" } else { cmd.Example = ` kernel vaults credentials create user-vault login --spec-file - <<'JSON' -{"description":"Hacker News","fields":{"username":{"type":"text","required":true,"sensitive":false},"password":{"type":"password","required":true,"sensitive":true}}} +{"description":"Hacker News","fields":[{"name":"username","type":"text","required":true,"sensitive":false},{"name":"password","type":"password","required":true,"sensitive":true}]} JSON` } cmd.Flags().String("spec-file", "", "Credential spec JSON file (use '-' for stdin; maximum 128 KiB)") diff --git a/cmd/vaults_credentials_test.go b/cmd/vaults_credentials_test.go index 8dc6ef2e..206392d0 100644 --- a/cmd/vaults_credentials_test.go +++ b/cmd/vaults_credentials_test.go @@ -15,7 +15,7 @@ import ( "github.com/stretchr/testify/require" ) -const credentialFixture = `{"id":"credential-1","key":"login","type":"credential","version":2,"spec":{"description":"Website login","fields":{"password":{"type":"password","required":true,"sensitive":true,"value":"never-print"}}},"state":{"status":"pending_collection","fields":{"password":{"has_value":false,"value":"never-print"}}},"action":{"name":"collect","url":"https://vault.kernel.sh/collect#token=item.random","expires_at":"2026-10-01T00:00:00Z"},"available_operations":[{"type":"collect","description":"Open the form"},{"type":"fill","description":"Fill the form"}],"available_expansions":[]}` +const credentialFixture = `{"id":"credential-1","key":"login","type":"credential","version":2,"spec":{"description":"Website login","fields":[{"name":"password","type":"password","required":true,"sensitive":true,"value":"never-print"}]},"state":{"status":"pending_collection","fields":{"password":{"has_value":false,"value":"never-print"}}},"action":{"name":"collect","url":"https://vault.kernel.sh/collect#token=item.random","expires_at":"2026-10-01T00:00:00Z"},"available_operations":[{"type":"collect","description":"Open the form"},{"type":"fill","description":"Fill the form"}],"available_expansions":[]}` func credentialSpecFile(t *testing.T, data string) string { t.Helper() @@ -42,12 +42,12 @@ func TestCredentialCreateAndUpdate(t *testing.T) { assert.JSONEq(t, `{"fields":{"password":{"value":null}}}`, string(body["spec"])) } else { assert.Equal(t, "PUT", r.Method) - assert.JSONEq(t, `{"fields":{"password":{"type":"password","required":true}}}`, string(body["spec"])) + assert.JSONEq(t, `{"fields":[{"name":"password","type":"password","required":true}]}`, string(body["spec"])) } w.Header().Set("Content-Type", "application/json") io.WriteString(w, credentialFixture) }) - args := []string{"vaults", "credentials", "create", "user", "login", "--spec-file", credentialSpecFile(t, `{"fields":{"password":{"type":"password","required":true}}}`), "-o", "json"} + args := []string{"vaults", "credentials", "create", "user", "login", "--spec-file", credentialSpecFile(t, `{"fields":[{"name":"password","type":"password","required":true}]}`), "-o", "json"} if update { args[2] = "update" args[6] = credentialSpecFile(t, `{"fields":{"password":{"value":null}}}`) @@ -75,7 +75,7 @@ func TestCredentialWriteErrorsAreRedactedAndNotRetried(t *testing.T) { io.WriteString(w, `{"message":"secret-echo"}`) }) c := VaultsCmd{vaults: &client.Vaults} - err := c.saveCredential(context.Background(), "user", "login", []byte(`{"fields":{"password":{"type":"password","value":"secret-echo"}}}`), false, 0, "", "json", false) + err := c.saveCredential(context.Background(), "user", "login", []byte(`{"fields":[{"name":"password","type":"password","value":"secret-echo"}]}`), false, 0, "", "json", false) require.Error(t, err) assert.NotContains(t, err.Error(), "secret-echo") assert.Equal(t, 1, calls) diff --git a/cmd/vaults_fill_credentials_test.go b/cmd/vaults_fill_credentials_test.go index 2432d9a5..c351e70c 100644 --- a/cmd/vaults_fill_credentials_test.go +++ b/cmd/vaults_fill_credentials_test.go @@ -13,7 +13,7 @@ import ( "github.com/stretchr/testify/require" ) -const readyFillCredentialFixture = `{"id":"credential-1","type":"credential","spec":{"fields":{"expiration":{"type":"password"},"custom field":{"type":"text"},"otp":{"type":"totp"}}},"state":{"status":"ready"},"available_operations":[{"type":"fill","description":"Fill credential fields."}]}` +const readyFillCredentialFixture = `{"id":"credential-1","type":"credential","spec":{"fields":[{"name":"expiration","type":"password"},{"name":"custom field","type":"text"},{"name":"otp","type":"totp"}]},"state":{"status":"ready"},"available_operations":[{"type":"fill","description":"Fill credential fields."}]}` func TestVaultFillBothItemTypesAndInputs(t *testing.T) { for _, input := range []string{"params", "spec-file"} { diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 5616e52b..49270fcf 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -41,7 +41,7 @@ var vaultItemFields = vaultOutputFields{ "provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil, "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, "context": nil, "expires_at": nil, "description": nil, - "fields": {"*": vaultFieldsOf("type required sensitive")}, + "fields": vaultFieldsOf("name type required sensitive"), "provider_config": vaultFieldsOf("id name"), "authorization": {"method": nil, "client": {"type": nil, "provider_config": vaultFieldsOf("id name")}}, "totals": vaultTotalFields, @@ -134,11 +134,13 @@ func filterVaultJSON(raw json.RawMessage, fields vaultOutputFields) (json.RawMes } func preservePublicCredentialValues(source, result vaultJSON) error { + type definition struct { + Name string `json:"name"` + Type string `json:"type"` + Sensitive *bool `json:"sensitive"` + } var spec struct { - Fields map[string]struct { - Type string `json:"type"` - Sensitive *bool `json:"sensitive"` - } `json:"fields"` + Fields []definition `json:"fields"` } var values struct { Fields map[string]struct { @@ -149,8 +151,12 @@ func preservePublicCredentialValues(source, result vaultJSON) error { if json.Unmarshal(source["spec"], &spec) != nil || json.Unmarshal(source["state"], &values) != nil || values.Fields == nil { return nil } + definitions := make(map[string]definition, len(spec.Fields)) + for _, field := range spec.Fields { + definitions[field.Name] = field + } for name, field := range values.Fields { - definition := spec.Fields[name] + definition := definitions[name] if definition.Sensitive == nil || *definition.Sensitive || (definition.Type != "text" && definition.Type != "email") || !field.HasValue { field.Value = nil } diff --git a/cmd/vaults_public_values_test.go b/cmd/vaults_public_values_test.go index 192c86ad..669e6e63 100644 --- a/cmd/vaults_public_values_test.go +++ b/cmd/vaults_public_values_test.go @@ -12,10 +12,10 @@ import ( "github.com/stretchr/testify/require" ) -const publicCredentialFixture = `{"id":"credential-1","key":"login","type":"credential","version":2,"spec":{"description":"Example","fields":{"username":{"type":"text","sensitive":false},"email":{"type":"email","sensitive":false},"password":{"type":"password"},"otp":{"type":"totp","sensitive":true}}},"state":{"status":"ready","fields":{"username":{"has_value":true,"value":"user-123"},"email":{"has_value":true,"value":"user@example.com"},"password":{"has_value":true,"value":"private-password"},"otp":{"has_value":true,"value":"private-seed"}}},"action":{"name":"collect","url":"https://vault.example/collect#token=user-123.token"},"available_operations":[{"type":"collect","description":"Open form"}],"available_expansions":[]}` +const publicCredentialFixture = `{"id":"credential-1","key":"login","type":"credential","version":2,"spec":{"description":"Example","fields":[{"name":"username","type":"text","sensitive":false},{"name":"email","type":"email","sensitive":false},{"name":"password","type":"password"},{"name":"otp","type":"totp","sensitive":true}]},"state":{"status":"ready","fields":{"username":{"has_value":true,"value":"user-123"},"email":{"has_value":true,"value":"user@example.com"},"password":{"has_value":true,"value":"private-password"},"otp":{"has_value":true,"value":"private-seed"}}},"action":{"name":"collect","url":"https://vault.example/collect#token=user-123.token"},"available_operations":[{"type":"collect","description":"Open form"}],"available_expansions":[]}` func TestVaultPublicValuesAcrossCommands(t *testing.T) { - spec := credentialSpecFile(t, `{"fields":{"username":{"type":"text","sensitive":false,"value":"user-123"}}}`) + spec := credentialSpecFile(t, `{"fields":[{"name":"username","type":"text","sensitive":false,"value":"user-123"}]}`) update := credentialSpecFile(t, `{"fields":{"username":{"value":"user-123"}}}`) for _, args := range [][]string{ {"vaults", "credentials", "create", "user-123", "login", "--spec-file", spec}, @@ -44,6 +44,24 @@ func TestVaultPublicValuesAcrossCommands(t *testing.T) { } } +func TestVaultCredentialDefinitionOrderIsPreserved(t *testing.T) { + out, err := filterVaultJSON(json.RawMessage(publicCredentialFixture), vaultItemFields) + require.NoError(t, err) + var item struct { + Spec struct { + Fields []struct { + Name string `json:"name"` + } `json:"fields"` + } `json:"spec"` + } + require.NoError(t, json.Unmarshal(out, &item)) + names := make([]string, 0, len(item.Spec.Fields)) + for _, field := range item.Spec.Fields { + names = append(names, field.Name) + } + assert.Equal(t, []string{"username", "email", "password", "otp"}, names) +} + func TestVaultPublicValueBoundary(t *testing.T) { for _, tc := range []struct { kind, sensitive string @@ -55,7 +73,7 @@ func TestVaultPublicValueBoundary(t *testing.T) { {"text", "false", false, false}, } { t.Run(fmt.Sprint(tc), func(t *testing.T) { - raw := fmt.Sprintf(`{"type":"credential","spec":{"fields":{"field":{"type":%q,"sensitive":%s}}},"state":{"fields":{"field":{"has_value":%t,"value":"test-value"}}}}`, tc.kind, tc.sensitive, tc.hasValue) + raw := fmt.Sprintf(`{"type":"credential","spec":{"fields":[{"name":"field","type":%q,"sensitive":%s}]},"state":{"fields":{"field":{"has_value":%t,"value":"test-value"}}}}`, tc.kind, tc.sensitive, tc.hasValue) out, err := filterVaultJSON(json.RawMessage(raw), vaultItemFields) require.NoError(t, err) assert.Equal(t, tc.visible, strings.Contains(string(out), "test-value")) diff --git a/cmd/vaults_sdk_contract_test.go b/cmd/vaults_sdk_contract_test.go index 29fb41eb..94d7ec74 100644 --- a/cmd/vaults_sdk_contract_test.go +++ b/cmd/vaults_sdk_contract_test.go @@ -64,9 +64,9 @@ func TestCredentialUpdateIdentityPrecondition(t *testing.T) { } } -func TestCredentialInitialValuesWithReleasedSDK(t *testing.T) { +func TestCredentialInitialValuesWithGeneratedSDK(t *testing.T) { t.Setenv("KERNEL_PROJECT", "") - spec := `{"description":"Example","fields":{"username":{"type":"text","sensitive":false,"value":"synthetic-user"},"email":{"type":"email","sensitive":false,"value":"test@example.com"},"password":{"type":"password","sensitive":true,"value":"synthetic-password"},"otp":{"type":"totp","sensitive":true,"value":"JBSWY3DPEHPK3PXP"}}}` + spec := `{"description":"Example","fields":[{"name":"username","type":"text","sensitive":false,"value":"synthetic-user"},{"name":"email","type":"email","sensitive":false,"value":"test@example.com"},{"name":"password","type":"password","sensitive":true,"value":"synthetic-password"},{"name":"otp","type":"totp","sensitive":true,"value":"JBSWY3DPEHPK3PXP"}]}` calls := 0 client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { calls++ diff --git a/go.mod b/go.mod index 9eb823f3..894a856c 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.107.0 + github.com/kernel/kernel-go-sdk v0.0.0-20260917183146-34ba0a529b1d 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 5f951cdc..43e6d68b 100644 --- a/go.sum +++ b/go.sum @@ -64,6 +64,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.0.0-20260917183146-34ba0a529b1d h1:l3ZGtfYu7VHlWtHkvxqStmru5qqHA3IPwRXncCPbdis= +github.com/kernel/kernel-go-sdk v0.0.0-20260917183146-34ba0a529b1d/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/kernel/kernel-go-sdk v0.107.0 h1:0m4RkOquA3irSYK7hNU8VmLhTyl1jeCSydpTQ2tjba0= github.com/kernel/kernel-go-sdk v0.107.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=