diff --git a/.planning/phases/03-operator-experience/03-01-PLAN.md b/.planning/phases/03-operator-experience/03-01-PLAN.md index 77e7d27..f10f68c 100644 --- a/.planning/phases/03-operator-experience/03-01-PLAN.md +++ b/.planning/phases/03-operator-experience/03-01-PLAN.md @@ -12,12 +12,14 @@ files_modified: - packages/shared/shared/models/tenant.py - packages/shared/shared/api/billing.py - packages/shared/shared/api/channels.py + - packages/shared/shared/api/llm_keys.py - packages/shared/shared/api/usage.py - packages/shared/shared/crypto.py - packages/orchestrator/orchestrator/agents/runner.py - packages/orchestrator/orchestrator/audit/logger.py - migrations/versions/005_billing_and_usage.py - tests/unit/test_key_encryption.py + - tests/unit/test_llm_keys_crud.py - tests/unit/test_slack_oauth.py - tests/unit/test_stripe_webhooks.py - tests/unit/test_usage_aggregation.py @@ -34,6 +36,7 @@ must_haves: truths: - "Audit events for LLM calls include prompt_tokens, completion_tokens, cost_usd, and provider in metadata" - "BYO API keys can be encrypted and decrypted without data loss using Fernet" + - "BYO API keys can be listed (redacted), created, and deleted via REST endpoints" - "Slack OAuth state can be HMAC-signed and verified for CSRF protection" - "Stripe webhook events can be processed idempotently" - "Token usage can be aggregated per agent and per provider from audit events" @@ -45,6 +48,8 @@ must_haves: provides: "KeyEncryptionService with Fernet encrypt/decrypt/rotate" - path: "packages/shared/shared/api/channels.py" provides: "Slack OAuth state generation/verification, OAuth callback endpoint" + - path: "packages/shared/shared/api/llm_keys.py" + provides: "LLM key CRUD endpoints: GET (list, redacted), POST (encrypt + store), DELETE" - path: "packages/shared/shared/api/billing.py" provides: "Stripe webhook handler, checkout session creation, billing portal session" - path: "packages/shared/shared/api/usage.py" @@ -64,13 +69,21 @@ must_haves: to: "PLATFORM_ENCRYPTION_KEY env var" via: "Fernet key loaded at init" pattern: "MultiFernet" + - from: "packages/shared/shared/api/llm_keys.py" + to: "packages/shared/shared/crypto.py" + via: "KeyEncryptionService.encrypt() on POST, never decrypts on GET" + pattern: "KeyEncryptionService" + - from: "packages/portal/app/(dashboard)/settings/api-keys/page.tsx" + to: "packages/shared/shared/api/llm_keys.py" + via: "GET/POST/DELETE /api/portal/tenants/{tenant_id}/llm-keys" + pattern: "tenants.*llm-keys" --- -Backend foundation for Phase 3: database migrations, dependency installs, audit trail token metadata, encryption service, and all backend API endpoints for billing, channel connection, and usage aggregation. +Backend foundation for Phase 3: database migrations, dependency installs, audit trail token metadata, encryption service, and all backend API endpoints for billing, channel connection, LLM key management, and usage aggregation. Purpose: Every portal UI feature in Phase 3 depends on backend APIs and database schema. This plan ships all backend infrastructure so Plans 02-04 can focus on frontend. -Output: New DB tables/fields, billing + channel + usage API endpoints, encryption service, enhanced audit logger, and comprehensive test scaffolds. +Output: New DB tables/fields, billing + channel + LLM key + usage API endpoints, encryption service, enhanced audit logger, and comprehensive test scaffolds. @@ -217,7 +230,7 @@ portal_router = APIRouter(prefix="/api/portal") - CREATE TABLE tenant_llm_keys with RLS enabled (same FORCE ROW LEVEL SECURITY pattern as agents) - CREATE TABLE stripe_events (event_id TEXT PK, processed_at TIMESTAMPTZ DEFAULT now()) - CREATE INDEX idx_audit_events_tenant_type_created ON audit_events (tenant_id, action_type, created_at DESC) - - GRANT SELECT, INSERT on tenant_llm_keys to konstruct_app + - GRANT SELECT, INSERT, DELETE on tenant_llm_keys to konstruct_app (DELETE needed for key removal) - GRANT SELECT, INSERT on stripe_events to konstruct_app 9. Write test scaffolds: @@ -233,7 +246,7 @@ portal_router = APIRouter(prefix="/api/portal") - TenantLlmKey and StripeEvent models exist in billing.py - KeyEncryptionService passes encrypt/decrypt/rotate tests - Budget alert threshold logic passes at all levels - - Alembic migration 005 exists with all schema changes + - Alembic migration 005 exists with all schema changes (including DELETE grant on tenant_llm_keys) - Config has all new settings fields @@ -287,7 +300,7 @@ portal_router = APIRouter(prefix="/api/portal") - `GET /api/portal/usage/{tenant_id}/budget-alerts` — for each agent with budget_limit_usd, compare current month cost_usd sum against limit. Return status: "ok" (<80%), "warning" (80-99%), "exceeded" (>=100%). - Include the composite index from migration 005 for performance. - 4. Register new routers in the appropriate main.py files. Add channels_router, billing_router, and usage_router to the FastAPI app. The stripe webhook route should be on a separate prefix (/api/webhooks/stripe) without auth. + 4. Register new routers in the appropriate main.py files. Add channels_router, billing_router, llm_keys_router, and usage_router to the FastAPI app. The stripe webhook route should be on a separate prefix (/api/webhooks/stripe) without auth. 5. Enhance audit logger — in `packages/orchestrator/orchestrator/agents/runner.py`, extend the metadata dict passed to `log_llm_call()` to include: - prompt_tokens: extracted from LiteLLM response usage object @@ -319,23 +332,68 @@ portal_router = APIRouter(prefix="/api/portal") + + Task 3: LLM key CRUD API endpoints + + packages/shared/shared/api/llm_keys.py, + tests/unit/test_llm_keys_crud.py + + + - test_create_llm_key: POST with {provider, label, api_key} encrypts key and returns {id, provider, label, created_at} (no key in response) + - test_list_llm_keys_redacted: GET returns list with provider, label, created_at, key_hint (last 4 chars) — never the full key + - test_delete_llm_key: DELETE removes key, subsequent GET no longer includes it + - test_create_duplicate_provider: POST with same tenant_id+provider returns 409 Conflict (UNIQUE constraint) + - test_delete_nonexistent_key: DELETE with unknown key_id returns 404 + + + 1. Create `packages/shared/shared/api/llm_keys.py`: + - `llm_keys_router = APIRouter(prefix="/api/portal/tenants/{tenant_id}/llm-keys")` + - `GET /api/portal/tenants/{tenant_id}/llm-keys` — list all BYO keys for tenant. Return list of {id, provider, label, key_hint, created_at}. key_hint = last 4 characters of the original key (stored alongside encrypted_key in a separate column, or computed at creation and stored in label metadata). NEVER decrypt the key for listing. + - `POST /api/portal/tenants/{tenant_id}/llm-keys` — accepts {provider: str, label: str, api_key: str}. Encrypt api_key using KeyEncryptionService.encrypt(). Store key_hint (last 4 chars of api_key) for display. Insert into tenant_llm_keys table. Return {id, provider, label, key_hint, created_at} with 201 status. Handle UNIQUE(tenant_id, provider) conflict -> return 409. + - `DELETE /api/portal/tenants/{tenant_id}/llm-keys/{key_id}` — delete the key row. Verify tenant_id matches to prevent cross-tenant deletion. Return 204 on success, 404 if not found. + - Use the same dependency injection pattern as existing portal endpoints (get_db session, tenant authorization). + + 2. Update migration 005 if needed: add `key_hint` column (VARCHAR(4), nullable=True) to tenant_llm_keys table for storing the last 4 chars safely without decryption on list. + + 3. Write tests in `tests/unit/test_llm_keys_crud.py`: + - test_create_llm_key: verify encrypted_key is stored (not plaintext), response has no api_key field + - test_list_llm_keys_redacted: verify response never contains encrypted_key or plaintext key, only key_hint + - test_delete_llm_key: verify removal and 204 status + - test_create_duplicate_provider: verify 409 on UNIQUE violation + - test_delete_nonexistent_key: verify 404 + + + cd /home/adelorenzo/repos/konstruct && pytest tests/unit/test_llm_keys_crud.py -x -v + + + - GET /api/portal/tenants/{tenant_id}/llm-keys returns redacted key list (provider, label, key_hint, created_at) + - POST creates encrypted key and returns 201 with no secret in response + - DELETE removes key and returns 204 + - Duplicate provider per tenant returns 409 + - Cross-tenant deletion prevented by tenant_id check + - All 5 tests pass + + + -All Wave 0 test scaffolds created and passing: +All test scaffolds created and passing: - `pytest tests/unit/test_key_encryption.py -x` — Fernet encrypt/decrypt/rotate - `pytest tests/unit/test_budget_alerts.py -x` — threshold logic - `pytest tests/unit/test_slack_oauth.py -x` — OAuth state HMAC - `pytest tests/unit/test_stripe_webhooks.py -x` — idempotency, status updates, cancellation - `pytest tests/unit/test_usage_aggregation.py -x` — SQL aggregates +- `pytest tests/unit/test_llm_keys_crud.py -x` — LLM key CRUD operations - `pytest tests/unit -x -q` — full unit suite still green -- All 5 test files pass with 0 failures +- All 6 test files pass with 0 failures - Alembic migration 005 exists and is syntactically valid -- New API routers registered and importable +- New API routers registered and importable (including llm_keys_router) - KeyEncryptionService encrypt/decrypt roundtrip works +- LLM key CRUD endpoints return redacted data (never expose plaintext keys) - Audit logger metadata includes prompt_tokens, completion_tokens, cost_usd, provider - Existing test suite remains green diff --git a/.planning/phases/03-operator-experience/03-02-PLAN.md b/.planning/phases/03-operator-experience/03-02-PLAN.md index 986e884..f7114ad 100644 --- a/.planning/phases/03-operator-experience/03-02-PLAN.md +++ b/.planning/phases/03-operator-experience/03-02-PLAN.md @@ -47,6 +47,10 @@ must_haves: to: "/api/portal/channels/{tenant_id}/test" via: "POST to send test message" pattern: "channels.*test" + - from: "packages/portal/app/(dashboard)/settings/api-keys/page.tsx" + to: "/api/portal/tenants/{tenant_id}/llm-keys" + via: "GET/POST/DELETE for BYO key CRUD (endpoints created in Plan 01 Task 3)" + pattern: "tenants.*llm-keys" --- @@ -80,6 +84,13 @@ From packages/shared/shared/api/channels.py (created in Plan 01): # POST /api/portal/channels/{tenant_id}/test -> { "success": true, "message": "Test message sent" } (body: { channel_type }) ``` +From packages/shared/shared/api/llm_keys.py (created in Plan 01 Task 3): +```python +# GET /api/portal/tenants/{tenant_id}/llm-keys -> [{ id, provider, label, key_hint, created_at }] +# POST /api/portal/tenants/{tenant_id}/llm-keys -> { id, provider, label, key_hint, created_at } (body: { provider, label, api_key }) +# DELETE /api/portal/tenants/{tenant_id}/llm-keys/{key_id} -> 204 +``` + From packages/shared/shared/api/billing.py (created in Plan 01): ```python # POST /api/portal/billing/checkout -> { "checkout_url": "https://checkout.stripe.com/..." } @@ -201,12 +212,12 @@ Established patterns: 1. Create `packages/portal/app/(dashboard)/settings/api-keys/page.tsx`: - Tenant-level settings page (per user decision — simpler than per-agent for v1) - - List existing BYO keys: show provider name, label, created date (NOT the key itself — never display decrypted keys) + - List existing BYO keys: show provider name, label, key_hint (last 4 chars), created date (NOT the key itself — never display decrypted keys) - "Add API Key" button opens a form: - Provider: select dropdown (OpenAI, Anthropic, Custom) - Label: text input (human-readable name, e.g., "Production OpenAI key") - API Key: password input (masked by default) - - Submit: POST to /api/portal/tenants/{tenant_id}/llm-keys (needs endpoint added to Plan 01's channels.py or a new file) + - Submit: POST to /api/portal/tenants/{tenant_id}/llm-keys (backend endpoint created in Plan 01 Task 3) - Delete button per key with confirmation dialog - Use shadcn/ui Card, Table, Dialog, Button, Input, Select components - react-hook-form + zod for validation (provider required, label 3-100 chars, key not empty) @@ -217,14 +228,12 @@ Established patterns: - useDeleteLlmKey() — mutation DELETE /api/portal/tenants/{tenant_id}/llm-keys/{keyId} 3. Add navigation link to settings/api-keys in the dashboard layout sidebar (if sidebar exists) or in the tenant detail page. - - Note: The backend endpoints for LLM key CRUD may need to be added to Plan 01's API if not already included. If the endpoint doesn't exist yet, create it in this task: add routes to packages/shared/shared/api/channels.py or create a separate packages/shared/shared/api/llm_keys.py with GET (list, redacted), POST (encrypt + store), DELETE. cd /home/adelorenzo/repos/konstruct/packages/portal && npx next build 2>&1 | tail -20 - - BYO API key settings page renders with list of existing keys (redacted) + - BYO API key settings page renders with list of existing keys (redacted — shows key_hint only) - Add key form validates and submits to backend - Delete key with confirmation dialog works - Portal builds without errors @@ -264,7 +273,7 @@ Established patterns: - Operator can paste WhatsApp credentials via guided form - Onboarding wizard completes in 3 steps: connect -> configure -> test - Agent goes live automatically after successful test message -- Operator can manage BYO API keys from settings page +- Operator can manage BYO API keys from settings page (backed by Plan 01 Task 3 endpoints) - Portal builds without errors diff --git a/.planning/phases/03-operator-experience/03-04-PLAN.md b/.planning/phases/03-operator-experience/03-04-PLAN.md index 815e9cc..5d32f55 100644 --- a/.planning/phases/03-operator-experience/03-04-PLAN.md +++ b/.planning/phases/03-operator-experience/03-04-PLAN.md @@ -2,8 +2,8 @@ phase: 03-operator-experience plan: 04 type: execute -wave: 3 -depends_on: ["03-01", "03-03"] +wave: 2 +depends_on: ["03-01"] files_modified: - packages/portal/app/(dashboard)/usage/[tenantId]/page.tsx - packages/portal/components/usage-chart.tsx @@ -69,7 +69,6 @@ Output: Usage dashboard page with Recharts visualizations, budget alert badges, @.planning/phases/03-operator-experience/03-CONTEXT.md @.planning/phases/03-operator-experience/03-RESEARCH.md @.planning/phases/03-operator-experience/03-01-SUMMARY.md -@.planning/phases/03-operator-experience/03-03-SUMMARY.md