Skip to content

Commit 570e0c4

Browse files
authored
Preserve credential field order (#261)
## Summary - accept ordered named credential definitions in `vaults credentials create` - preserve ordered definitions in display-safe output - steer callers to follow the website's natural top-to-bottom order - test against the generated Go SDK preview from kernel/kernel#4163 ## Rollout - depends on kernel/kernel#4163 - the preview SDK pin must be replaced with the published SDK version during the coordinated release ## Testing - `go test ./...` <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Breaking change for `credentials create` JSON (map → ordered array) tied to a preview SDK/API rollout; touches credential spec handling and public value redaction, though update/fill paths are largely unchanged. > > **Overview** > **Credential create specs now use an ordered `fields` array** (`[{"name":"username",...}]`) instead of a name-keyed object, matching the updated Go SDK preview. Array order is preserved in display-safe JSON output and drives the hosted collection form layout. > > Help text and **README** vault examples now tell callers to list fields in the site’s natural top-to-bottom order. `preservePublicCredentialValues` and `vaultItemFields` were updated for the array shape; tests and fixtures follow suit, including a check that definition order survives filtering. **Credential update** specs remain map-based for partial value changes. > > `go.mod` pins **`kernel-go-sdk`** to a generated preview (`kernel/kernel#4163`) until a coordinated release replaces it with a published version. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit dda95bf. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 32a8053 commit 570e0c4

12 files changed

Lines changed: 56 additions & 24 deletions

‎README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,13 +275,14 @@ Vault commands **collect user credentials and manage payment credentials; fill d
275275
#### User credentials
276276

277277
Create a vault for the end user, attach it when creating a browser, then navigate to the
278-
sensitive form. Define the observed fields without supplying values:
278+
sensitive form. Define the observed fields in the website's natural top-to-bottom order;
279+
the collection form renders this array order unchanged. Omit values for user collection:
279280

280281
```sh
281282
kernel vaults create --name user-vault
282283
kernel browsers create --vault user-vault
283284
kernel vaults credentials create user-vault login --spec-file - <<'JSON'
284-
{"description":"Hacker News","fields":{"username":{"type":"text","required":true,"sensitive":false},"password":{"type":"password","required":true,"sensitive":true}}}
285+
{"description":"Hacker News","fields":[{"name":"username","type":"text","required":true,"sensitive":false},{"name":"password","type":"password","required":true,"sensitive":true}]}
285286
JSON
286287
kernel vaults items get user-vault login --wait 60 -o json
287288
kernel vaults items invoke user-vault login fill --spec-file - <<'JSON'

‎cmd/vaults_commands.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Use wallet and card item types for credit cards and payment checkout instead.
5959
6060
User credential flow:
6161
1. Create a vault per end user and create a browser with --vault <id-or-name>.
62-
2. Navigate to a sensitive form and define its fields with credentials create --spec-file.
62+
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.
6363
3. Present the returned collection URL to the user. Poll items get --wait 60 for ready.
6464
4. Use items invoke <vault> <key> fill --spec-file with browser_id and field selectors.
6565
Use credentials update --version for edits, or items invoke collect to reopen the form.

‎cmd/vaults_credential_guidance_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ func TestCredentialHumanGuidance(t *testing.T) {
2222
args := []string{"vaults", "items", "get", "user", "login"}
2323
switch operation {
2424
case "create":
25-
args = []string{"vaults", "credentials", "create", "user", "login", "--spec-file", credentialSpecFile(t, `{"fields":{"password":{"type":"password"}}}`)}
25+
args = []string{"vaults", "credentials", "create", "user", "login", "--spec-file", credentialSpecFile(t, `{"fields":[{"name":"password","type":"password"}]}`)}
2626
case "collect":
2727
args = []string{"vaults", "items", "invoke", "user", "login", "collect"}
2828
}

‎cmd/vaults_credential_steering_test.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ func TestCredentialHelpSteering(t *testing.T) {
3030
assert.Contains(t, strings.Join(strings.Fields(cmd.Long), " "), "Use wallet and card item types for credit cards and payment checkout instead")
3131
assert.Contains(t, newVaultsCommand().Long, "Use wallet and card item types")
3232
assert.Contains(t, cmd.Long, "recognizable site name only")
33+
assert.Contains(t, cmd.Long, "natural top-to-bottom order")
34+
assert.Contains(t, cmd.Long, "collection form renders that order unchanged")
3335
assert.Contains(t, cmd.Long, "sensitive:false explicitly for ordinary usernames and email addresses")
3436
assert.Contains(t, cmd.Example, `"description":"Hacker News"`)
37+
assert.Contains(t, cmd.Example, `"fields":[{"name":"username"`)
3538
assert.Contains(t, cmd.Example, `"sensitive":false`)
3639
}

‎cmd/vaults_credentials.go‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ card item types for credit cards and payment checkout instead.
2121
2222
First create a vault for the end user and attach it with browsers create --vault.
2323
Use a protected JSON file or stdin, never secret values in shell arguments.
24-
The spec contains description and fields keyed by name. Field types are text,
25-
email, password, and totp; definitions accept required, sensitive, and value.
24+
The spec contains description and fields as an ordered array of named definitions.
25+
Inspect the website and list fields in its natural top-to-bottom order because the
26+
user-facing collection form renders that order unchanged. Field types are text,
27+
email, password, and totp; definitions accept name, required, sensitive, and value.
2628
Set description to the recognizable site name only, e.g. "Hacker News", not
2729
"Hacker News sign-in credentials". This text is the user-facing form title.
2830
Set sensitive:false explicitly for ordinary usernames and email addresses.
@@ -66,7 +68,7 @@ func newVaultCredentialsCommand() *cobra.Command {
6668
cmd.Example = " kernel vaults credentials update user-vault login --version 2 --spec-file changes.json"
6769
} else {
6870
cmd.Example = ` kernel vaults credentials create user-vault login --spec-file - <<'JSON'
69-
{"description":"Hacker News","fields":{"username":{"type":"text","required":true,"sensitive":false},"password":{"type":"password","required":true,"sensitive":true}}}
71+
{"description":"Hacker News","fields":[{"name":"username","type":"text","required":true,"sensitive":false},{"name":"password","type":"password","required":true,"sensitive":true}]}
7072
JSON`
7173
}
7274
cmd.Flags().String("spec-file", "", "Credential spec JSON file (use '-' for stdin; maximum 128 KiB)")

‎cmd/vaults_credentials_test.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import (
1515
"github.com/stretchr/testify/require"
1616
)
1717

18-
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":[]}`
18+
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":[]}`
1919

2020
func credentialSpecFile(t *testing.T, data string) string {
2121
t.Helper()
@@ -42,12 +42,12 @@ func TestCredentialCreateAndUpdate(t *testing.T) {
4242
assert.JSONEq(t, `{"fields":{"password":{"value":null}}}`, string(body["spec"]))
4343
} else {
4444
assert.Equal(t, "PUT", r.Method)
45-
assert.JSONEq(t, `{"fields":{"password":{"type":"password","required":true}}}`, string(body["spec"]))
45+
assert.JSONEq(t, `{"fields":[{"name":"password","type":"password","required":true}]}`, string(body["spec"]))
4646
}
4747
w.Header().Set("Content-Type", "application/json")
4848
io.WriteString(w, credentialFixture)
4949
})
50-
args := []string{"vaults", "credentials", "create", "user", "login", "--spec-file", credentialSpecFile(t, `{"fields":{"password":{"type":"password","required":true}}}`), "-o", "json"}
50+
args := []string{"vaults", "credentials", "create", "user", "login", "--spec-file", credentialSpecFile(t, `{"fields":[{"name":"password","type":"password","required":true}]}`), "-o", "json"}
5151
if update {
5252
args[2] = "update"
5353
args[6] = credentialSpecFile(t, `{"fields":{"password":{"value":null}}}`)
@@ -75,7 +75,7 @@ func TestCredentialWriteErrorsAreRedactedAndNotRetried(t *testing.T) {
7575
io.WriteString(w, `{"message":"secret-echo"}`)
7676
})
7777
c := VaultsCmd{vaults: &client.Vaults}
78-
err := c.saveCredential(context.Background(), "user", "login", []byte(`{"fields":{"password":{"type":"password","value":"secret-echo"}}}`), false, 0, "", "json", false)
78+
err := c.saveCredential(context.Background(), "user", "login", []byte(`{"fields":[{"name":"password","type":"password","value":"secret-echo"}]}`), false, 0, "", "json", false)
7979
require.Error(t, err)
8080
assert.NotContains(t, err.Error(), "secret-echo")
8181
assert.Equal(t, 1, calls)

‎cmd/vaults_fill_credentials_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313
"github.com/stretchr/testify/require"
1414
)
1515

16-
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."}]}`
16+
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."}]}`
1717

1818
func TestVaultFillBothItemTypesAndInputs(t *testing.T) {
1919
for _, input := range []string{"params", "spec-file"} {

‎cmd/vaults_output.go‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ var vaultItemFields = vaultOutputFields{
4141
"provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil,
4242
"amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil,
4343
"context": nil, "expires_at": nil, "description": nil,
44-
"fields": {"*": vaultFieldsOf("type required sensitive")},
44+
"fields": vaultFieldsOf("name type required sensitive"),
4545
"provider_config": vaultFieldsOf("id name"),
4646
"authorization": {"method": nil, "client": {"type": nil, "provider_config": vaultFieldsOf("id name")}},
4747
"totals": vaultTotalFields,
@@ -134,11 +134,13 @@ func filterVaultJSON(raw json.RawMessage, fields vaultOutputFields) (json.RawMes
134134
}
135135

136136
func preservePublicCredentialValues(source, result vaultJSON) error {
137+
type definition struct {
138+
Name string `json:"name"`
139+
Type string `json:"type"`
140+
Sensitive *bool `json:"sensitive"`
141+
}
137142
var spec struct {
138-
Fields map[string]struct {
139-
Type string `json:"type"`
140-
Sensitive *bool `json:"sensitive"`
141-
} `json:"fields"`
143+
Fields []definition `json:"fields"`
142144
}
143145
var values struct {
144146
Fields map[string]struct {
@@ -149,8 +151,12 @@ func preservePublicCredentialValues(source, result vaultJSON) error {
149151
if json.Unmarshal(source["spec"], &spec) != nil || json.Unmarshal(source["state"], &values) != nil || values.Fields == nil {
150152
return nil
151153
}
154+
definitions := make(map[string]definition, len(spec.Fields))
155+
for _, field := range spec.Fields {
156+
definitions[field.Name] = field
157+
}
152158
for name, field := range values.Fields {
153-
definition := spec.Fields[name]
159+
definition := definitions[name]
154160
if definition.Sensitive == nil || *definition.Sensitive || (definition.Type != "text" && definition.Type != "email") || !field.HasValue {
155161
field.Value = nil
156162
}

‎cmd/vaults_public_values_test.go‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ import (
1212
"github.com/stretchr/testify/require"
1313
)
1414

15-
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":[]}`
15+
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":[]}`
1616

1717
func TestVaultPublicValuesAcrossCommands(t *testing.T) {
18-
spec := credentialSpecFile(t, `{"fields":{"username":{"type":"text","sensitive":false,"value":"user-123"}}}`)
18+
spec := credentialSpecFile(t, `{"fields":[{"name":"username","type":"text","sensitive":false,"value":"user-123"}]}`)
1919
update := credentialSpecFile(t, `{"fields":{"username":{"value":"user-123"}}}`)
2020
for _, args := range [][]string{
2121
{"vaults", "credentials", "create", "user-123", "login", "--spec-file", spec},
@@ -44,6 +44,24 @@ func TestVaultPublicValuesAcrossCommands(t *testing.T) {
4444
}
4545
}
4646

47+
func TestVaultCredentialDefinitionOrderIsPreserved(t *testing.T) {
48+
out, err := filterVaultJSON(json.RawMessage(publicCredentialFixture), vaultItemFields)
49+
require.NoError(t, err)
50+
var item struct {
51+
Spec struct {
52+
Fields []struct {
53+
Name string `json:"name"`
54+
} `json:"fields"`
55+
} `json:"spec"`
56+
}
57+
require.NoError(t, json.Unmarshal(out, &item))
58+
names := make([]string, 0, len(item.Spec.Fields))
59+
for _, field := range item.Spec.Fields {
60+
names = append(names, field.Name)
61+
}
62+
assert.Equal(t, []string{"username", "email", "password", "otp"}, names)
63+
}
64+
4765
func TestVaultPublicValueBoundary(t *testing.T) {
4866
for _, tc := range []struct {
4967
kind, sensitive string
@@ -55,7 +73,7 @@ func TestVaultPublicValueBoundary(t *testing.T) {
5573
{"text", "false", false, false},
5674
} {
5775
t.Run(fmt.Sprint(tc), func(t *testing.T) {
58-
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)
76+
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)
5977
out, err := filterVaultJSON(json.RawMessage(raw), vaultItemFields)
6078
require.NoError(t, err)
6179
assert.Equal(t, tc.visible, strings.Contains(string(out), "test-value"))

‎cmd/vaults_sdk_contract_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,9 @@ func TestCredentialUpdateIdentityPrecondition(t *testing.T) {
6464
}
6565
}
6666

67-
func TestCredentialInitialValuesWithReleasedSDK(t *testing.T) {
67+
func TestCredentialInitialValuesWithGeneratedSDK(t *testing.T) {
6868
t.Setenv("KERNEL_PROJECT", "")
69-
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"}}}`
69+
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"}]}`
7070
calls := 0
7171
client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) {
7272
calls++

0 commit comments

Comments
 (0)