docs/superpowers/plans/2026-04-20-instance-tls-per-field-source.md
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the current global inline/file-path TLS source behavior with independent source choices for CA material and client certificate/key material.
Architecture: Keep use_ssl as the only persisted on/off switch and keep the existing path fields. Backend validation rejects inline/path conflicts only within the same material slot, resolves path fields into PEM independently, and validates the effective CA plus effective client cert/key pair. The React form removes the global TLS source selector and uses local-only source selectors for the CA group and client certificate/key group.
Tech Stack: Go, proto-generated Go structs, Connect API validation, React, TypeScript, react-i18next, Vitest, pnpm, golangci-lint.
backend/api/v1/instance_service_tls_test.go: update tests from global-source semantics to same-slot conflict and cross-slot mixing semantics.backend/api/v1/instance_service.go: change TLS write validation and normalization helpers.backend/plugin/db/util/ssl_test.go: add coverage that path resolution overwrites only matching slots and preserves inline material in other slots.backend/plugin/db/util/ssl.go: keep per-field resolution behavior; adjust only if the new test exposes old global assumptions.frontend/src/react/components/instance/tls.ts: replace LocalTlsSource with LocalTlsCaSource and LocalTlsClientCertSource helpers.frontend/src/react/components/instance/common.test.ts: test source inference and per-group clearing helpers.frontend/src/react/components/instance/SslCertificateForm.tsx: replace the global source selector with CA and client certificate/key source selectors.frontend/src/react/components/instance/SslCertificateForm.test.tsx: test System trust rendering and per-group source controls.frontend/src/react/components/instance/DataSourceForm.tsx: keep separate local CA and client source state and update only the corresponding fields.frontend/src/react/locales/*.json and frontend/src/locales/*.json: update TLS source labels from one global source label to CA/client group labels.Files:
Modify: backend/api/v1/instance_service_tls_test.go
Modify: backend/api/v1/instance_service.go
Step 1: Replace the old global mixed-source failing tests
In backend/api/v1/instance_service_tls_test.go, replace TestValidateDataSourceTLSWriteRejectsMixedExplicitMaterial, TestNormalizeDataSourceTLSClearsInlineWhenPathWins, TestNormalizeDataSourceTLSClearsStaleInlineWhenPathExists, TestNormalizeDataSourceTLSClearsPathsWhenSwitchingToInline, and TestValidateDataSourceTLSWriteRejectsInactiveInlineWithExistingPath with tests that encode the new behavior:
func TestValidateDataSourceTLSWriteRejectsSameSlotMixedMaterial(t *testing.T) {
tests := []struct {
name string
ds *storepb.DataSource
mask []string
want string
}{
{
name: "ca",
ds: &storepb.DataSource{UseSsl: true, SslCa: "inline-ca", SslCaPath: "/tmp/ca.pem"},
mask: []string{"ssl_ca", "ssl_ca_path"},
want: "cannot set both ssl_ca and ssl_ca_path",
},
{
name: "cert",
ds: &storepb.DataSource{UseSsl: true, SslCert: "inline-cert", SslCertPath: "/tmp/cert.pem"},
mask: []string{"ssl_cert", "ssl_cert_path"},
want: "cannot set both ssl_cert and ssl_cert_path",
},
{
name: "key",
ds: &storepb.DataSource{UseSsl: true, SslKey: "inline-key", SslKeyPath: "/tmp/key.pem"},
mask: []string{"ssl_key", "ssl_key_path"},
want: "cannot set both ssl_key and ssl_key_path",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := validateDataSourceTLSWrite(tc.ds, tc.ds, tc.mask)
require.Error(t, err)
require.Contains(t, err.Error(), tc.want)
})
}
}
func TestValidateDataSourceTLSWriteAllowsCrossSlotMixedMaterial(t *testing.T) {
err := validateDataSourceTLSWrite(
&storepb.DataSource{UseSsl: true, SslCa: validCAPEM, SslCertPath: "/tmp/cert.pem", SslKeyPath: "/tmp/key.pem"},
&storepb.DataSource{UseSsl: true, SslCa: validCAPEM, SslCertPath: "/tmp/cert.pem", SslKeyPath: "/tmp/key.pem"},
[]string{"ssl_ca", "ssl_cert_path", "ssl_key_path"},
)
require.NoError(t, err)
}
func TestNormalizeDataSourceTLSClearsSameSlotConflictsOnly(t *testing.T) {
ds := &storepb.DataSource{
UseSsl: true,
SslCa: "inline-ca",
SslCaPath: "/tmp/ca.pem",
SslCert: "inline-cert",
SslKeyPath: "/tmp/key.pem",
}
normalizeDataSourceTLS(ds, []string{"ssl_ca_path", "ssl_key_path"})
require.Empty(t, ds.GetSslCa())
require.Equal(t, "/tmp/ca.pem", ds.GetSslCaPath())
require.Equal(t, "inline-cert", ds.GetSslCert())
require.Equal(t, "/tmp/key.pem", ds.GetSslKeyPath())
}
Define validCAPEM in the test file with this valid self-signed certificate PEM:
const validCAPEM = `-----BEGIN CERTIFICATE-----
MIIDOTCCAiGgAwIBAgIQSRJrEpBGFc7tNb1fb5pKFzANBgkqhkiG9w0BAQsFADAS
MRAwDgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYw
MDAwWjASMRAwDgYDVQQKEwdBY21lIENvMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
MIIBCgKCAQEA6Gba5tHV1dAKouAaXO3/ebDUU4rvwCUg/CNaJ2PT5xLD4N1Vcb8r
bFSW2HXKq+MPfVdwIKR/1DczEoAGf/JWQTW7EgzlXrCd3rlajEX2D73faWJekD0U
aUgz5vtrTXZ90BQL7WvRICd7FlEZ6FPOcPlumiyNmzUqtwGhO+9ad1W5BqJaRI6P
YfouNkwR6Na4TzSj5BrqUfP0FwDizKSJ0XXmh8g8G9mtwxOSN3Ru1QFc61Xyeluk
POGKBV/q6RBNklTNe0gI8usUMlYyoC7ytppNMW7X2vodAelSu25jgx2anj9fDVZu
h7AXF5+4nJS4AAt0n1lNY7nGSsdZas8PbQIDAQABo4GIMIGFMA4GA1UdDwEB/wQE
AwICpDATBgNVHSUEDDAKBggrBgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud
DgQWBBStsdjh3/JCXXYlQryOrL4Sh7BW5TAuBgNVHREEJzAlggtleGFtcGxlLmNv
bYcEfwAAAYcQAAAAAAAAAAAAAAAAAAAAATANBgkqhkiG9w0BAQsFAAOCAQEAxWGI
5NhpF3nwwy/4yB4i/CwwSpLrWUa70NyhvprUBC50PxiXav1TeDzwzLx/o5HyNwsv
cxv3HdkLW59i/0SlJSrNnWdfZ19oTcS+6PtLoVyISgtyN6DpkKpdG1cOkW3Cy2P2
+tK/tKHRP1Y/Ra0RiDpOAmqn0gCOFGz8+lqDIor/T7MTpibL3IxqWfPrvfVRHL3B
grw/ZQTTIVjjh4JBSW3WyWgNo/ikC1lrVxzl4iPUGptxT36Cr7Zk2Bsg0XqwbOvK
5d+NTDREkSnUbie4GeutujmX3Dsx88UiV6UY/4lHJa6I5leHUNOHahRbpbWeOfs/
WkBKOclmOV2xlTVuPw==
-----END CERTIFICATE-----`
Run:
go test ./backend/api/v1 -run 'TestValidateDataSourceTLSWrite|TestNormalizeDataSourceTLS|TestTLSMaskPaths|TestValidateDataSourceTLSConfig' -count=1
Expected: FAIL because current validation rejects any explicit inline+path write and current normalization clears all inline material when any path exists.
In backend/api/v1/instance_service.go, replace hasExplicitInlineTLSMaterial/hasExplicitPathTLSMaterial based rejection with slot-specific checks:
func validateDataSourceTLSWrite(requested, merged *storepb.DataSource, mask []string) error {
if !merged.GetUseSsl() {
return nil
}
for _, conflict := range []struct {
inlineField string
pathField string
inlineValue string
pathValue string
}{
{"ssl_ca", "ssl_ca_path", merged.GetSslCa(), merged.GetSslCaPath()},
{"ssl_cert", "ssl_cert_path", merged.GetSslCert(), merged.GetSslCertPath()},
{"ssl_key", "ssl_key_path", merged.GetSslKey(), merged.GetSslKeyPath()},
} {
if conflict.inlineValue == "" || conflict.pathValue == "" {
continue
}
if tlsMaskContains(mask, conflict.inlineField) || tlsMaskContains(mask, conflict.pathField) {
return errors.Errorf("cannot set both %s and %s", conflict.inlineField, conflict.pathField)
}
}
return validateDataSourceTLSConfig(merged)
}
Update validateDataSourceTLSConfig so path absoluteness is checked whenever path fields are provided, and inline PEM validation still runs when inline PEM is present. Do not return early just because any path field exists.
Change normalizeDataSourceTLS to clear only conflicting inactive values for fields included in the update mask:
func normalizeDataSourceTLS(ds *storepb.DataSource, mask []string) {
if !ds.GetUseSsl() {
clearInlineTLSMaterial(ds)
clearPathTLSMaterial(ds)
return
}
if tlsMaskContains(mask, "ssl_ca") {
ds.SslCaPath = ""
}
if tlsMaskContains(mask, "ssl_ca_path") {
ds.SslCa = ""
}
if tlsMaskContains(mask, "ssl_cert") {
ds.SslCertPath = ""
}
if tlsMaskContains(mask, "ssl_cert_path") {
ds.SslCert = ""
}
if tlsMaskContains(mask, "ssl_key") {
ds.SslKeyPath = ""
}
if tlsMaskContains(mask, "ssl_key_path") {
ds.SslKey = ""
}
}
Run:
go test ./backend/api/v1 -run 'TestValidateDataSourceTLSWrite|TestNormalizeDataSourceTLS|TestTLSMaskPaths|TestValidateDataSourceTLSConfig' -count=1
Expected: PASS.
Run:
gofmt -w backend/api/v1/instance_service.go backend/api/v1/instance_service_tls_test.go
git add backend/api/v1/instance_service.go backend/api/v1/instance_service_tls_test.go
git commit -m "fix(instance): allow per-field TLS material sources"
Files:
Modify: backend/plugin/db/util/ssl_test.go
Modify: backend/plugin/db/util/ssl.go
Step 1: Add failing resolver test
Add this test to backend/plugin/db/util/ssl_test.go:
func TestResolveTLSMaterialPreservesInlineCrossSlotMaterial(t *testing.T) {
dir := t.TempDir()
keyPath := filepath.Join(dir, "key.pem")
require.NoError(t, os.WriteFile(keyPath, []byte("path-key"), 0o600))
ds := &storepb.DataSource{
UseSsl: true,
SslCa: "inline-ca",
SslCert: "inline-cert",
SslKeyPath: keyPath,
}
resolved, err := ResolveTLSMaterial(ds)
require.NoError(t, err)
require.Equal(t, "inline-ca", resolved.GetSslCa())
require.Equal(t, "inline-cert", resolved.GetSslCert())
require.Equal(t, "path-key", resolved.GetSslKey())
require.Empty(t, resolved.GetSslKeyPath())
require.Equal(t, keyPath, ds.GetSslKeyPath())
}
Run:
go test ./backend/plugin/db/util -run 'TestResolveTLSMaterial' -count=1
Expected: PASS if current resolver already behaves per-field. If it fails, the failure should show a global-source assumption in ResolveTLSMaterial.
If the new test fails, change ResolveTLSMaterial so each path field overwrites only the matching inline field and clears only the matching path field. It should not clear SslCa, SslCert, or SslKey because another path field exists.
Run:
gofmt -w backend/plugin/db/util/ssl.go backend/plugin/db/util/ssl_test.go
go test ./backend/plugin/db/util -run 'TestResolveTLSMaterial' -count=1
git add backend/plugin/db/util/ssl.go backend/plugin/db/util/ssl_test.go
git commit -m "test(instance): cover mixed TLS material resolution"
Files:
Modify: frontend/src/react/components/instance/tls.ts
Modify: frontend/src/react/components/instance/common.test.ts
Step 1: Write failing helper tests
Replace frontend/src/react/components/instance/common.test.ts with tests for per-group helpers:
import { describe, expect, test } from "vitest";
import {
applyLocalTlsCaSource,
applyLocalTlsClientCertSource,
getLocalTlsCaSource,
getLocalTlsClientCertSource,
SSL_UPDATE_MASK_FIELDS,
} from "./tls";
describe("TLS update mask fields", () => {
test("includes the SSL path fields alongside inline material", () => {
expect(SSL_UPDATE_MASK_FIELDS).toEqual([
"use_ssl",
"ssl_ca",
"ssl_cert",
"ssl_key",
"ssl_ca_path",
"ssl_cert_path",
"ssl_key_path",
]);
});
});
describe("TLS local source helpers", () => {
test("treats empty CA material with SSL enabled as system trust", () => {
expect(getLocalTlsCaSource({ useSsl: true })).toBe("SYSTEM_TRUST");
});
test("clears only CA fields when selecting system trust", () => {
const next = applyLocalTlsCaSource(
{
useSsl: true,
sslCa: "inline-ca",
sslCaPath: "/tmp/ca.pem",
sslCert: "inline-cert",
sslKeyPath: "/tmp/key.pem",
} as never,
"SYSTEM_TRUST"
);
expect(next.sslCa).toBe("");
expect(next.sslCaPath).toBe("");
expect(next.sslCert).toBe("inline-cert");
expect(next.sslKeyPath).toBe("/tmp/key.pem");
});
test("clears only client cert fields when selecting none", () => {
const next = applyLocalTlsClientCertSource(
{
useSsl: true,
sslCaPath: "/tmp/ca.pem",
sslCert: "inline-cert",
sslKey: "inline-key",
sslCertPath: "/tmp/cert.pem",
sslKeyPath: "/tmp/key.pem",
} as never,
"NONE"
);
expect(next.sslCaPath).toBe("/tmp/ca.pem");
expect(next.sslCert).toBe("");
expect(next.sslKey).toBe("");
expect(next.sslCertPath).toBe("");
expect(next.sslKeyPath).toBe("");
});
test("infers client certificate source from path presence flags", () => {
expect(getLocalTlsClientCertSource({ useSsl: true, hasSslCertPath: true } as never)).toBe("FILE_PATH");
});
});
Run:
pnpm --dir frontend exec vitest run src/react/components/instance/common.test.ts
Expected: FAIL because the helper functions do not exist yet.
In frontend/src/react/components/instance/tls.ts, replace LocalTlsSource with:
export const LOCAL_TLS_CA_SOURCE_SYSTEM_TRUST = "SYSTEM_TRUST" as const;
export const LOCAL_TLS_CA_SOURCE_INLINE_PEM = "INLINE_PEM" as const;
export const LOCAL_TLS_CA_SOURCE_FILE_PATH = "FILE_PATH" as const;
export const LOCAL_TLS_CLIENT_CERT_SOURCE_NONE = "NONE" as const;
export const LOCAL_TLS_CLIENT_CERT_SOURCE_INLINE_PEM = "INLINE_PEM" as const;
export const LOCAL_TLS_CLIENT_CERT_SOURCE_FILE_PATH = "FILE_PATH" as const;
export type LocalTlsCaSource =
| typeof LOCAL_TLS_CA_SOURCE_SYSTEM_TRUST
| typeof LOCAL_TLS_CA_SOURCE_INLINE_PEM
| typeof LOCAL_TLS_CA_SOURCE_FILE_PATH;
export type LocalTlsClientCertSource =
| typeof LOCAL_TLS_CLIENT_CERT_SOURCE_NONE
| typeof LOCAL_TLS_CLIENT_CERT_SOURCE_INLINE_PEM
| typeof LOCAL_TLS_CLIENT_CERT_SOURCE_FILE_PATH;
Implement getLocalTlsCaSource, getLocalTlsClientCertSource, applyLocalTlsCaSource, applyLocalTlsClientCertSource, and disableLocalTls so CA changes clear only CA fields and client source changes clear only cert/key fields.
Run:
pnpm --dir frontend exec vitest run src/react/components/instance/common.test.ts
Expected: PASS.
Run:
pnpm --dir frontend fix
git add frontend/src/react/components/instance/tls.ts frontend/src/react/components/instance/common.test.ts
git commit -m "feat(frontend): split TLS material source helpers"
Files:
Modify: frontend/src/react/components/instance/SslCertificateForm.tsx
Modify: frontend/src/react/components/instance/SslCertificateForm.test.tsx
Modify: frontend/src/react/components/instance/DataSourceForm.tsx
Modify: frontend/src/react/locales/en-US.json
Modify: frontend/src/react/locales/es-ES.json
Modify: frontend/src/react/locales/ja-JP.json
Modify: frontend/src/react/locales/vi-VN.json
Modify: frontend/src/react/locales/zh-CN.json
Modify: frontend/src/locales/en-US.json
Modify: frontend/src/locales/es-ES.json
Modify: frontend/src/locales/ja-JP.json
Modify: frontend/src/locales/vi-VN.json
Modify: frontend/src/locales/zh-CN.json
Step 1: Write failing form test
Extend frontend/src/react/components/instance/SslCertificateForm.test.tsx with:
it("renders explicit CA and client certificate source controls", () => {
render(
<SslCertificateForm
useSsl={true}
caSource="SYSTEM_TRUST"
onCaSourceChange={() => {}}
clientCertSource="FILE_PATH"
onClientCertSourceChange={() => {}}
showKeyAndCert={true}
/>
);
expect(screen.getByText("data-source.ssl.ca-source.self")).toBeTruthy();
expect(screen.getByText("data-source.ssl.ca-source.system-trust")).toBeTruthy();
expect(screen.getByText("data-source.ssl.client-cert-source.self")).toBeTruthy();
expect(screen.getByText("data-source.ssl.client-cert-source.file-path")).toBeTruthy();
});
Run:
pnpm --dir frontend exec vitest run src/react/components/instance/SslCertificateForm.test.tsx
Expected: FAIL because caSource and clientCertSource props are not defined.
In SslCertificateForm.tsx, replace source/onSourceChange with:
caSource?: LocalTlsCaSource;
onCaSourceChange?: (val: LocalTlsCaSource) => void;
clientCertSource?: LocalTlsClientCertSource;
onClientCertSourceChange?: (val: LocalTlsClientCertSource) => void;
Render CA certificate source radio options above CA material. For SYSTEM_TRUST, show no CA input and show the system trust hint. For INLINE_PEM, show the CA textarea. For FILE_PATH, show the CA path input.
Render Client certificate source radio options before client material when client cert/key fields are supported. For NONE, show no cert/key inputs. For INLINE_PEM, show the key/cert textareas. For FILE_PATH, show cert/key path inputs.
In DataSourceForm.tsx, replace localTlsSource state with:
const [localTlsCaSource, setLocalTlsCaSource] = useState(getLocalTlsCaSource(dataSource));
const [localTlsClientCertSource, setLocalTlsClientCertSource] = useState(getLocalTlsClientCertSource(dataSource));
On data source changes, sync both local states. Pass both sources to SslCertificateForm. On CA source change, call applyLocalTlsCaSource; on client source change, call applyLocalTlsClientCertSource. When SSL is disabled, call disableLocalTls.
Add React and Vue locale keys:
"data-source.ssl.ca-source.self": "CA Certificate Source",
"data-source.ssl.ca-source.system-trust": "System Trust",
"data-source.ssl.client-cert-source.self": "Client Certificate Source",
"data-source.ssl.client-cert-source.none": "None"
Reuse existing data-source.ssl.source.inline-pem and data-source.ssl.source.file-path labels unless the i18n check requires group-specific keys.
Run:
pnpm --dir frontend fix
pnpm --dir frontend exec vitest run src/react/components/instance/common.test.ts src/react/components/instance/SslCertificateForm.test.tsx
pnpm --dir frontend check
pnpm --dir frontend type-check
Expected: all commands exit 0.
Run:
git add frontend/src/react/components/instance/SslCertificateForm.tsx frontend/src/react/components/instance/SslCertificateForm.test.tsx frontend/src/react/components/instance/DataSourceForm.tsx frontend/src/react/locales frontend/src/locales
git commit -m "feat(frontend): choose TLS sources per material group"
Files:
Verify all files changed by Tasks 1 through 4.
Step 1: Run proto checks
Run:
PATH="$(go env GOPATH)/bin:$PATH" buf format -w proto
PATH="$(go env GOPATH)/bin:$PATH" buf lint proto
(cd proto && PATH="$(go env GOPATH)/bin:$PATH" buf generate)
Expected: exit 0. If grpc-doc HTML files change only by whitespace, revert those generated-doc whitespace-only changes before committing.
Run:
golangci-lint run --allow-parallel-runners
go test ./backend/api/v1 ./backend/plugin/db/util ./backend/component/dbfactory ./backend/component/ghost ./backend/plugin/db/elasticsearch -count=1
go test ./backend/plugin/db/mongodb -run '^TestBuildMongoshBaseArgsUsesTempDirForTLSFiles$' -count=1
Expected: exit 0. Do not run the full MongoDB package unless mongosh is installed.
Run:
pnpm --dir frontend check
pnpm --dir frontend type-check
pnpm --dir frontend exec vitest run src/react/components/instance/common.test.ts src/react/components/instance/SslCertificateForm.test.tsx
Expected: exit 0.
Run:
go build -ldflags "-w -s" -p=16 -o ./bytebase-build/bytebase ./backend/bin/server/main.go
Expected: exit 0.
If verification caused tracked changes, inspect them with:
git diff --stat
git diff --check
Then commit only intentional fixes:
git add <changed-files>
git commit -m "fix(instance): finalize TLS per-field source behavior"
Expected: git status --short is clean when done.