12 KiB
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— unrelatedGenerationVersion.java— no need to track which key was useddocker-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
@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
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— AcceptLLMConfigRequest, encrypt the key, store inUser.llmApiKeysGET /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
@Valueannotations
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
@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, injectProviderFactory,UserRepository,EncryptionService generateSite(UUID userId, UUID siteId, String prompt, List<Reference> references, String provider, String apiKey)— look up key from user if not provided; callcallLLM()refineSite(...)— same treatmentcallLLM()now takesprovider+apiKey: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
/generateand/refine: extractprovider(required) and optionalapiKeyfrom request body - If
apiKeynot provided, load from user's stored keys viaUserRepository+EncryptionService.decrypt() - Pass
provider+apiKeytoLLMService - 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-providerandindie.llm.fallback-provider - Remove
indie.llm.providers.gemini.api-keyandindie.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
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-configsaveKey(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
LLMKeyStatusfrom 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
LLMKeyStatusviallm-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 optionallyapiKey) 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