add ssl certificates and tests
This commit is contained in:
284
PLAN-byok.md
Normal file
284
PLAN-byok.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# Bring Your Own Key (BYOK) — Execution Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Allow users to provide their own API keys for LLM providers (Gemini, OpenAI) instead of relying on globally configured environment variables. Users can store keys for multiple providers and select which one to use during site generation. Remove all default/global API key configuration.
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
### What will be changed
|
||||
|
||||
| Area | Files |
|
||||
|------|-------|
|
||||
| User entity | `User.java` — add `Map<String, String> llmApiKeys` (JSON column, encrypted values) |
|
||||
| Encryption | `EncryptionService.java` — AES-GCM encrypt/decrypt for stored keys |
|
||||
| LLM providers | `GeminiProvider.java`, `OpenAIProvider.java` — remove `@Component`, remove `@Value` apiKey, accept all params in constructor |
|
||||
| Provider creation | `ProviderFactory.java` (new) — creates provider instances per-request with user's key |
|
||||
| LLM factory | `LLMFactory.java` — delete, replaced by `ProviderFactory` |
|
||||
| LLM service | `LLMService.java` — accept `provider`+`apiKey` params, use `ProviderFactory`, remove fallback logic |
|
||||
| LLM controller | `LLMController.java` — extract `provider`+`apiKey` from request; fall back to user's stored keys |
|
||||
| User settings API | `UserSettingsController.java` (new) — `PUT/GET/DELETE /api/user/llm-config` |
|
||||
| DTOs | `LLMConfigRequest.java` (new) — `{provider, apiKey}` |
|
||||
| Config | `application.yml` — remove `active-provider`, `fallback-provider`, `api-key` entries |
|
||||
| Frontend types | `llm.ts` — add `provider` field to requests; add `LLMKeyStatus` type |
|
||||
| Frontend service | `llm-config.service.ts` (new) — API calls for key CRUD |
|
||||
| Frontend settings | `settings.component.ts` — add LLM API keys card (provider list + key input) |
|
||||
| Frontend workspace | `llm-workspace.component.ts` — load key status, pass provider in generate/refine calls |
|
||||
| Tests | All modified files + new tests for `EncryptionService`, `ProviderFactory`, `UserSettingsController` |
|
||||
|
||||
### What will NOT be changed
|
||||
|
||||
- Storage keys (S3/MinIO) — infrastructure concern
|
||||
- Git remote tokens — separate feature
|
||||
- JWT signing keys — internal auth
|
||||
- Rate limiting — kept as-is
|
||||
- `Site.java`, `GitRemote.java`, `SelfHostedConfig.java` — unrelated
|
||||
- `GenerationVersion.java` — no need to track which key was used
|
||||
- `docker-compose.yml` — unrelated
|
||||
|
||||
---
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
### 1. Multiple provider keys stored per user
|
||||
|
||||
`User.java` gains a JSON column `llmApiKeys` mapping provider names to encrypted API keys:
|
||||
|
||||
```
|
||||
{"gemini": "AES-encrypted-value", "openai": "AES-encrypted-value"}
|
||||
```
|
||||
|
||||
Stored as a `Map<String, String>` with Hibernate 6 `@JdbcTypeCode(SqlTypes.JSON)`. Each value is encrypted individually via `EncryptionService`.
|
||||
|
||||
### 2. No global/default keys
|
||||
|
||||
The env-var-based API keys (`GEMINI_API_KEY`, `OPENAI_API_KEY`) are removed. Every user must configure their own key(s). If no key is available for the requested provider, the API returns a clear error: "No API key configured for {provider}. Add one in Settings."
|
||||
|
||||
### 3. Per-request provider instances
|
||||
|
||||
`LLMProvider` implementations are no longer Spring beans. A `ProviderFactory` creates a fresh instance per request with the user's key. This keeps the `LLMProvider` interface clean (no `apiKey` parameter on `generate()`).
|
||||
|
||||
### 4. Provider selected per request
|
||||
|
||||
Each generation/refine request includes a `provider` field. The user's stored key for that provider is used. The request body can optionally include an `apiKey` to override the stored key on-the-fly.
|
||||
|
||||
### 5. Encryption
|
||||
|
||||
AES-256-GCM via `EncryptionService`. Master key from env var `INDIE_ENCRYPTION_KEY`. If unset, a key is generated and persisted to disk (same pattern as JWT keys in `JwtConfig`).
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: Backend — Encryption service
|
||||
|
||||
**New file:** `backend/src/main/java/com/krrishg/config/EncryptionService.java`
|
||||
|
||||
- AES-256-GCM encrypt/decrypt
|
||||
- Master key loaded from `@Value("${indie.encryption.master-key:}")`
|
||||
- If empty, generate random key and save to `config/encryption.key`
|
||||
- Methods: `encrypt(String plaintext) → String`, `decrypt(String ciphertext) → String`
|
||||
|
||||
### Step 2: Backend — Add LLM API keys to User entity
|
||||
|
||||
**Modify:** `backend/src/main/java/com/krrishg/model/User.java`
|
||||
|
||||
```java
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private Map<String, String> llmApiKeys;
|
||||
```
|
||||
|
||||
Import `java.util.Map`, `org.hibernate.type.SqlTypes`, `jakarta.persistence.Column`.
|
||||
|
||||
### Step 3: Backend — LLM config DTO and controller
|
||||
|
||||
**New file:** `backend/src/main/java/com/krrishg/dto/LLMConfigRequest.java`
|
||||
|
||||
```java
|
||||
public record LLMConfigRequest(String provider, String apiKey) {}
|
||||
```
|
||||
|
||||
**New file:** `backend/src/main/java/com/krrishg/controller/UserSettingsController.java`
|
||||
|
||||
- `@RestController @RequestMapping("/api/user")`
|
||||
- `PUT /llm-config` — Accept `LLMConfigRequest`, encrypt the key, store in `User.llmApiKeys`
|
||||
- `GET /llm-config` — Return map of `{provider: boolean}` indicating which keys are configured (never return actual keys)
|
||||
- `DELETE /llm-config/{provider}` — Remove the key for that provider
|
||||
|
||||
### Step 4: Backend — Refactor LLM providers
|
||||
|
||||
**Modify:** `backend/src/main/java/com/krrishg/service/llm/GeminiProvider.java`
|
||||
|
||||
- Remove `@Component`
|
||||
- Constructor: `GeminiProvider(String apiKey, String model, String baseUrl, RestTemplate restTemplate)`
|
||||
- Remove all `@Value` annotations
|
||||
|
||||
**Modify:** `backend/src/main/java/com/krrishg/service/llm/OpenAIProvider.java`
|
||||
|
||||
- Same refactoring as GeminiProvider
|
||||
|
||||
### Step 5: Backend — Create ProviderFactory
|
||||
|
||||
**New file:** `backend/src/main/java/com/krrishg/service/llm/ProviderFactory.java`
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class ProviderFactory {
|
||||
// Injected via @Value: model names, base URLs from application.yml
|
||||
// Methods:
|
||||
// createProvider("gemini", apiKey) → new GeminiProvider(...)
|
||||
// createProvider("openai", apiKey) → new OpenAIProvider(...)
|
||||
// getAvailableProviders() → ["gemini", "openai"]
|
||||
}
|
||||
```
|
||||
|
||||
**Delete:** `backend/src/main/java/com/krrishg/service/llm/LLMFactory.java`
|
||||
|
||||
### Step 6: Backend — Update LLMService
|
||||
|
||||
**Modify:** `backend/src/main/java/com/krrishg/service/LLMService.java`
|
||||
|
||||
- Remove `LLMFactory`, inject `ProviderFactory`, `UserRepository`, `EncryptionService`
|
||||
- `generateSite(UUID userId, UUID siteId, String prompt, List<Reference> references, String provider, String apiKey)` — look up key from user if not provided; call `callLLM()`
|
||||
- `refineSite(...)` — same treatment
|
||||
- `callLLM()` now takes `provider` + `apiKey`:
|
||||
```java
|
||||
private LLMResult callLLM(..., String providerName, String apiKey) {
|
||||
if (apiKey == null || apiKey.isBlank()) {
|
||||
throw new IllegalStateException("No API key configured for " + providerName);
|
||||
}
|
||||
LLMProvider provider = providerFactory.createProvider(providerName, apiKey);
|
||||
return provider.generate(systemPrompt, userPrompt, references, options);
|
||||
}
|
||||
```
|
||||
- Remove the old active/fallback provider logic entirely
|
||||
|
||||
### Step 7: Backend — Update LLMController
|
||||
|
||||
**Modify:** `backend/src/main/java/com/krrishg/controller/LLMController.java`
|
||||
|
||||
- In `/generate` and `/refine`: extract `provider` (required) and optional `apiKey` from request body
|
||||
- If `apiKey` not provided, load from user's stored keys via `UserRepository` + `EncryptionService.decrypt()`
|
||||
- Pass `provider` + `apiKey` to `LLMService`
|
||||
- Add validation: provider must be one of the supported values
|
||||
|
||||
### Step 8: Backend — Update application.yml
|
||||
|
||||
**Modify:** `backend/src/main/resources/application.yml`
|
||||
|
||||
- Remove `indie.llm.active-provider` and `indie.llm.fallback-provider`
|
||||
- Remove `indie.llm.providers.gemini.api-key` and `indie.llm.providers.openai.api-key`
|
||||
- Keep `indie.llm.providers.gemini.model`, `gemini.url`, `openai.model`, `openai.url`
|
||||
- Add `indie.encryption.master-key: ${INDIE_ENCRYPTION_KEY:}`
|
||||
|
||||
### Step 9: Frontend — Update LLM types
|
||||
|
||||
**Modify:** `frontend/src/app/core/models/llm.ts`
|
||||
|
||||
```typescript
|
||||
export interface LLMGenerateRequest {
|
||||
prompt: string;
|
||||
siteId: string;
|
||||
provider: string;
|
||||
apiKey?: string;
|
||||
references?: Reference[];
|
||||
}
|
||||
|
||||
export interface LLMRefineRequest {
|
||||
prompt: string;
|
||||
siteId: string;
|
||||
provider: string;
|
||||
currentStructure: SiteStructure;
|
||||
apiKey?: string;
|
||||
references?: Reference[];
|
||||
}
|
||||
|
||||
export interface LLMKeyStatus {
|
||||
gemini: boolean;
|
||||
openai: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 10: Frontend — LLM config service
|
||||
|
||||
**New file:** `frontend/src/app/core/services/llm-config.service.ts`
|
||||
|
||||
- `getStatus(): Observable<LLMKeyStatus>` — GET `/api/user/llm-config`
|
||||
- `saveKey(provider: string, apiKey: string): Observable<void>` — PUT `/api/user/llm-config`
|
||||
|
||||
### Step 11: Frontend — Add LLM keys UI to settings
|
||||
|
||||
**Modify:** `frontend/src/app/llm-workspace/settings/settings.component.ts`
|
||||
|
||||
Add a new card "LLM API Keys":
|
||||
- For each provider (Gemini, OpenAI): show a row with provider name, status indicator (configured/not configured), and expandable input for the API key
|
||||
- "Save" button per provider
|
||||
- On init, load `LLMKeyStatus` from backend
|
||||
- On save, call `llm-config.service.saveKey()`
|
||||
|
||||
### Step 12: Frontend — Update workspace
|
||||
|
||||
**Modify:** `frontend/src/app/llm-workspace/llm-workspace.component.ts`
|
||||
|
||||
- On init, load `LLMKeyStatus` via `llm-config.service`
|
||||
- If no keys configured, show a notice: "Configure an API key in Settings to generate sites"
|
||||
- If only one provider has a key, auto-select it
|
||||
- If multiple, add a dropdown to select provider before generating
|
||||
- Pass `provider` (and optionally `apiKey`) in generate/refine requests
|
||||
|
||||
### Step 13: Backend — Tests
|
||||
|
||||
| Test file | What to test |
|
||||
|-----------|-------------|
|
||||
| `EncryptionServiceTest.java` (new) | Encrypt → decrypt round-trip; different keys produce different ciphertexts |
|
||||
| `ProviderFactoryTest.java` (new) | Creates correct provider type for each name; throws for unknown provider |
|
||||
| `LLMControllerTest.java` (update) | Request with/without provider, with/without apiKey, missing key returns 400 |
|
||||
| `LLMServiceTest.java` (update) | Uses ProviderFactory instead of LLMFactory; passes key correctly; no fallback logic |
|
||||
| `UserSettingsControllerTest.java` (new) | Save key, get status, delete key |
|
||||
|
||||
### Step 14: Frontend — Tests (optional)
|
||||
|
||||
Add/tests for:
|
||||
- `llm-config.service.ts`
|
||||
- New settings UI code
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
### Test scenarios
|
||||
|
||||
| # | Scenario | Expected Behavior |
|
||||
|---|----------|------------------|
|
||||
| 1 | User has no keys configured | Workspace shows notice; API returns 400 with clear message |
|
||||
| 2 | User saves Gemini key | `PUT /api/user/llm-config` returns success; `GET /api/user/llm-config` shows `gemini: true` |
|
||||
| 3 | User saves OpenAI key | Same as above |
|
||||
| 4 | User saves both keys | `GET` returns both as configured |
|
||||
| 5 | Generate with stored Gemini key | Request with `provider: "gemini"` succeeds |
|
||||
| 6 | Generate with stored OpenAI key | Request with `provider: "openai"` succeeds |
|
||||
| 7 | Generate with per-request key override | Passing `apiKey` in request body uses that key instead of stored |
|
||||
| 8 | Generate with unknown provider | 400 error |
|
||||
| 9 | Generate with provider that has no key stored | 400 error: "No API key configured for {provider}" |
|
||||
| 10 | Delete a stored key | `DELETE` clears it; subsequent generate for that provider fails |
|
||||
| 11 | Encryption round-trip | `decrypt(encrypt("key"))` == `"key"` |
|
||||
| 12 | Provider factory creates correct instances | GeminiProvider for "gemini", OpenAIProvider for "openai" |
|
||||
|
||||
### Risks and mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|-----------|
|
||||
| Encryption master key changes → stored keys unreadable | Generate + persist key to file on first run (like JWT keys); document `INDIE_ENCRYPTION_KEY` as critical env var |
|
||||
| Existing users have no key → immediate errors | Clear error messages in API responses and UI prompts |
|
||||
| Per-request provider creation overhead | `RestTemplate` shared via injected `RestTemplateBuilder`; negligible cost |
|
||||
| Hibernate JSON column compatibility | Use `@JdbcTypeCode(SqlTypes.JSON)` (Hibernate 6); fallback to `columnDefinition = "TEXT"` with manual JSON serialization if needed |
|
||||
|
||||
### Success criteria
|
||||
|
||||
- Users can configure API keys for Gemini and/or OpenAI via settings UI
|
||||
- Users can select which provider to use during site generation
|
||||
- Keys are encrypted at rest in the database
|
||||
- No global/default API key is required to run the application
|
||||
- All existing tests pass with appropriate modifications
|
||||
Reference in New Issue
Block a user