feat: implement Result types for type-safe error handling (Phase 1.1)

Replace throw/catch patterns with explicit Result types throughout the
codebase. This provides type-safe error handling and better user-facing
error messages.

## Changes

### Core Type System (src/types.ts)
- Add Result<T, E> discriminated union type
- Add AsyncResult<T, E> for promises
- Add helper functions: Ok(), Err(), tryCatch(), tryCatchAsync()

### Crypto Module (src/lib/crypto.ts)
- Update parsePublicKeyFromCert() to return Result<PublicKey, string>
- Update encryptValue() to return Result<string, string>
- Update encryptKeyValues() to return Result<Record<string, string>, string>
- Early return on first encryption failure with detailed error

### Controller API (src/lib/controller.ts)
- Update fetchPublicCertificate() to return AsyncResult<string, string>
- Update verifySealedSecret() to return AsyncResult<boolean, string>
- Update rotateSealedSecret() to return AsyncResult<string, string>
- Use tryCatchAsync() for HTTP operations

### UI Components
- EncryptDialog: Explicit error checking at each step with specific messages
- SealingKeysView: Type-safe certificate download with error handling
- DecryptDialog: Import cleanup (auto-fixed by linter)
- SealedSecretDetail: Unused import removed (auto-fixed by linter)

### Documentation
- ENHANCEMENT_PLAN.md: Comprehensive 4-phase enhancement roadmap
- PHASE_1.1_COMPLETE.md: Detailed implementation summary
- BUILD_VERIFICATION_SUMMARY.md: Build metrics and verification results
- DEVELOPMENT.md: Development workflow guide
- TESTING_GUIDE.md: Manual testing procedures
- READY_FOR_TESTING.md: Quick-start testing guide

### Development Tools
- Add 5 specialized Claude Code subagents to .claude/agents/
  - typescript-pro: TypeScript expertise
  - kubernetes-specialist: K8s best practices
  - react-specialist: React optimization
  - security-auditor: Security review
  - code-reviewer: Code quality

## Benefits

- Type Safety: Errors are now part of type signatures
- Better UX: Specific error messages at each operation step
- Maintainability: Error paths are explicit and visible
- No Hidden Exceptions: All error cases handled explicitly

## Verification

- TypeScript: 0 errors
- Linting: All checks pass
- Build: 340.13 kB (93.40 kB gzipped, +0.2%)
- Package: Successfully created

## Breaking Changes

None for users. Internal API signatures changed but plugin behavior is
backward compatible.

## Testing

See TESTING_GUIDE.md for detailed test scenarios:
- Happy path: Create sealed secret with valid controller
- Error path: Try with controller unreachable
- Console check: Verify no uncaught exceptions

Run: npm start (in headlamp-sealed-secrets directory)

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
2026-02-11 21:09:10 -05:00
parent b0d86831b7
commit 286e88fece
18 changed files with 5619 additions and 56 deletions
+31 -19
View File
@@ -5,8 +5,7 @@
* via the Kubernetes API proxy.
*/
import { request } from '@kinvolk/headlamp-plugin/lib/lib/k8s/apiProxy';
import { PluginConfig } from '../types';
import { AsyncResult, Err, PluginConfig, tryCatchAsync } from '../types';
/**
* Build the controller proxy URL
@@ -20,21 +19,26 @@ export function getControllerProxyURL(config: PluginConfig, path: string): strin
* Fetch the controller's public certificate
*
* @param config Plugin configuration
* @returns PEM-encoded certificate
* @returns Result containing PEM-encoded certificate or error message
*/
export async function fetchPublicCertificate(config: PluginConfig): Promise<string> {
export async function fetchPublicCertificate(
config: PluginConfig
): AsyncResult<string, string> {
const url = getControllerProxyURL(config, '/v1/cert.pem');
try {
const result = await tryCatchAsync(async () => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch certificate: ${response.status} ${response.statusText}`);
}
const cert = await response.text();
return cert;
} catch (error) {
throw new Error(`Unable to fetch controller certificate: ${error}`);
return await response.text();
});
if (result.ok === false) {
return Err(`Unable to fetch controller certificate: ${result.error.message}`);
}
return result;
}
/**
@@ -42,14 +46,15 @@ export async function fetchPublicCertificate(config: PluginConfig): Promise<stri
*
* @param config Plugin configuration
* @param sealedSecretYaml YAML or JSON of the SealedSecret
* @returns Result containing verification status or error message
*/
export async function verifySealedSecret(
config: PluginConfig,
sealedSecretYaml: string
): Promise<boolean> {
): AsyncResult<boolean, string> {
const url = getControllerProxyURL(config, '/v1/verify');
try {
const result = await tryCatchAsync(async () => {
const response = await fetch(url, {
method: 'POST',
headers: {
@@ -59,10 +64,13 @@ export async function verifySealedSecret(
});
return response.ok;
} catch (error) {
console.error('Verification failed:', error);
return false;
});
if (result.ok === false) {
return Err(`Verification failed: ${result.error.message}`);
}
return result;
}
/**
@@ -70,15 +78,15 @@ export async function verifySealedSecret(
*
* @param config Plugin configuration
* @param sealedSecretYaml YAML or JSON of the SealedSecret
* @returns The re-encrypted SealedSecret
* @returns Result containing the re-encrypted SealedSecret or error message
*/
export async function rotateSealedSecret(
config: PluginConfig,
sealedSecretYaml: string
): Promise<string> {
): AsyncResult<string, string> {
const url = getControllerProxyURL(config, '/v1/rotate');
try {
const result = await tryCatchAsync(async () => {
const response = await fetch(url, {
method: 'POST',
headers: {
@@ -92,9 +100,13 @@ export async function rotateSealedSecret(
}
return await response.text();
} catch (error) {
throw new Error(`Unable to rotate SealedSecret: ${error}`);
});
if (result.ok === false) {
return Err(`Unable to rotate SealedSecret: ${result.error.message}`);
}
return result;
}
/**
+31 -20
View File
@@ -12,18 +12,23 @@
*/
import forge from 'node-forge';
import { SealedSecretScope } from '../types';
import { Err, Ok, Result, SealedSecretScope } from '../types';
/**
* Parse a PEM certificate and extract the RSA public key
*
* @param pemCert PEM-encoded certificate string
* @returns Result containing the public key or an error message
*/
export function parsePublicKeyFromCert(pemCert: string): forge.pki.rsa.PublicKey {
export function parsePublicKeyFromCert(
pemCert: string
): Result<forge.pki.rsa.PublicKey, string> {
try {
const cert = forge.pki.certificateFromPem(pemCert);
const publicKey = cert.publicKey as forge.pki.rsa.PublicKey;
return publicKey;
return Ok(publicKey);
} catch (error) {
throw new Error(`Failed to parse certificate: ${error}`);
return Err(`Failed to parse certificate: ${error}`);
}
}
@@ -36,7 +41,7 @@ export function parsePublicKeyFromCert(pemCert: string): forge.pki.rsa.PublicKey
* @param name The secret name (for strict scoping)
* @param key The key name within the secret
* @param scope The encryption scope
* @returns Base64-encoded encrypted value
* @returns Result containing base64-encoded encrypted value or error message
*/
export function encryptValue(
publicKey: forge.pki.rsa.PublicKey,
@@ -45,7 +50,7 @@ export function encryptValue(
name: string,
key: string,
scope: SealedSecretScope
): string {
): Result<string, string> {
try {
// Generate a random 32-byte (256-bit) AES session key
const sessionKey = forge.random.getBytesSync(32);
@@ -86,15 +91,16 @@ export function encryptValue(
// Construct the sealed secret format:
// [2-byte length of encrypted session key][encrypted session key][IV][encrypted value][auth tag]
const sessionKeyLength = encryptedSessionKey.length;
const lengthBytes = String.fromCharCode((sessionKeyLength >> 8) & 0xff) +
String.fromCharCode(sessionKeyLength & 0xff);
const lengthBytes =
String.fromCharCode((sessionKeyLength >> 8) & 0xff) +
String.fromCharCode(sessionKeyLength & 0xff);
const payload = lengthBytes + encryptedSessionKey + iv + encryptedValue + tag;
// Base64 encode the final payload
return forge.util.encode64(payload);
return Ok(forge.util.encode64(payload));
} catch (error) {
throw new Error(`Encryption failed: ${error}`);
return Err(`Encryption failed: ${error}`);
}
}
@@ -106,7 +112,7 @@ export function encryptValue(
* @param namespace The namespace
* @param name The secret name
* @param scope The encryption scope
* @returns Object mapping keys to encrypted values
* @returns Result containing object mapping keys to encrypted values, or error message
*/
export function encryptKeyValues(
publicKey: forge.pki.rsa.PublicKey,
@@ -114,24 +120,29 @@ export function encryptKeyValues(
namespace: string,
name: string,
scope: SealedSecretScope
): Record<string, string> {
): Result<Record<string, string>, string> {
const encryptedData: Record<string, string> = {};
for (const { key, value } of keyValues) {
encryptedData[key] = encryptValue(publicKey, value, namespace, name, key, scope);
const result = encryptValue(publicKey, value, namespace, name, key, scope);
if (result.ok === false) {
return Err(`Failed to encrypt key '${key}': ${result.error}`);
}
encryptedData[key] = result.value;
}
return encryptedData;
return Ok(encryptedData);
}
/**
* Validate a PEM certificate
*
* @param pemCert PEM-encoded certificate string
* @returns true if certificate is valid, false otherwise
*/
export function validateCertificate(pemCert: string): boolean {
try {
parsePublicKeyFromCert(pemCert);
return true;
} catch {
return false;
}
const result = parsePublicKeyFromCert(pemCert);
return result.ok;
}