From 91e2faf5d0e8deaaba38042f563cbfb4a154b55f Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 20 Apr 2026 17:19:17 -0700 Subject: [PATCH] fix: tier bypass, draft body persistence, canDraftLlm cleanup, limit cap - CRITICAL: Remove X-CF-Tier header trust from _get_effective_tier; use Heimdall in cloud mode and APP_TIER env var in single-tenant only - HIGH: Add update_message_body helper + PUT /api/messages/{id} endpoint; updateMessageBody store action; approveDraft now persists edits to DB before calling approve so history always shows the final approved text - Cleanup: Remove dead canDraftLlm ref, checkLlmAvailable function, and v-else-if Enable LLM drafts link; show Draft reply button unconditionally - MEDIUM: Cap GET /api/messages limit param with Query(ge=1, le=1000) - Test: Update test_draft_without_llm_returns_402 to patch effective_tier instead of sending X-CF-Tier header --- dev-api.py | 30 ++++++++++++++++++-------- scripts/messaging.py | 15 +++++++++++++ tests/test_messaging_integration.py | 7 +++--- web/src/stores/messaging.ts | 12 ++++++++++- web/src/views/MessagingView.vue | 33 +++++------------------------ 5 files changed, 55 insertions(+), 42 deletions(-) diff --git a/dev-api.py b/dev-api.py index 625d129..c1deaa8 100644 --- a/dev-api.py +++ b/dev-api.py @@ -26,7 +26,7 @@ import yaml from bs4 import BeautifulSoup from contextlib import asynccontextmanager -from fastapi import FastAPI, HTTPException, Request, Response, UploadFile +from fastapi import FastAPI, HTTPException, Query, Request, Response, UploadFile from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel @@ -4195,6 +4195,10 @@ class MessageCreateBody(BaseModel): logged_at: Optional[str] = None +class MessageUpdateBody(BaseModel): + body: str + + class TemplateCreateBody(BaseModel): title: str category: str = "custom" @@ -4216,7 +4220,7 @@ def get_messages( job_id: Optional[int] = None, type: Optional[str] = None, direction: Optional[str] = None, - limit: int = 100, + limit: int = Query(default=100, ge=1, le=1000), ): from scripts.messaging import list_messages return list_messages( @@ -4241,6 +4245,15 @@ def del_message(message_id: int): raise HTTPException(404, "message not found") +@app.put("/api/messages/{message_id}") +def put_message(message_id: int, body: MessageUpdateBody): + from scripts.messaging import update_message_body + try: + return update_message_body(Path(_request_db.get() or DB_PATH), message_id, body.body) + except KeyError: + raise HTTPException(404, "message not found") + + @app.get("/api/message-templates") def get_templates(): from scripts.messaging import list_templates @@ -4282,24 +4295,23 @@ def del_template(template_id: int): # ── LLM Reply Draft (BSL 1.1) ───────────────────────────────────────────────── -def _get_effective_tier(request: Request) -> str: - """Resolve effective tier from request header or environment.""" - header_tier = request.headers.get("X-CF-Tier") - if header_tier: - return header_tier +def _get_effective_tier() -> str: + """Resolve effective tier: Heimdall in cloud mode, APP_TIER env var in single-tenant.""" + if _CLOUD_MODE: + return _resolve_cloud_tier() from app.wizard.tiers import effective_tier return effective_tier() @app.post("/api/contacts/{contact_id}/draft-reply") -def draft_reply(contact_id: int, request: Request): +def draft_reply(contact_id: int): """Generate an LLM draft reply for an inbound job_contacts row. Tier-gated.""" from app.wizard.tiers import can_use, has_configured_llm from scripts.messaging import create_message from scripts.llm_reply_draft import generate_draft_reply db_path = Path(_request_db.get() or DB_PATH) - tier = _get_effective_tier(request) + tier = _get_effective_tier() if not can_use(tier, "llm_reply_draft", has_byok=has_configured_llm()): raise HTTPException(402, detail={"error": "tier_required", "min_tier": "free+byok"}) diff --git a/scripts/messaging.py b/scripts/messaging.py index b2a2363..4cccefc 100644 --- a/scripts/messaging.py +++ b/scripts/messaging.py @@ -268,3 +268,18 @@ def delete_template(db_path: Path, template_id: int) -> None: con.commit() finally: con.close() + + +def update_message_body(db_path: Path, message_id: int, body: str) -> dict: + """Update the body text of a draft message before approval. Returns updated row.""" + con = _connect(db_path) + try: + row = con.execute("SELECT id FROM messages WHERE id=?", (message_id,)).fetchone() + if not row: + raise KeyError(f"message {message_id} not found") + con.execute("UPDATE messages SET body=? WHERE id=?", (body, message_id)) + con.commit() + updated = con.execute("SELECT * FROM messages WHERE id=?", (message_id,)).fetchone() + return dict(updated) + finally: + con.close() diff --git a/tests/test_messaging_integration.py b/tests/test_messaging_integration.py index fc4dd19..c25c4bb 100644 --- a/tests/test_messaging_integration.py +++ b/tests/test_messaging_integration.py @@ -159,12 +159,11 @@ def test_draft_without_llm_returns_402(fresh_db, monkeypatch): # Ensure has_configured_llm returns False at both import locations monkeypatch.setattr("app.wizard.tiers.has_configured_llm", lambda *a, **kw: False) + # Force free tier via the tiers module (not via header — header is no longer trusted) + monkeypatch.setattr("app.wizard.tiers.effective_tier", lambda: "free") client = TestClient(dev_api.app) - resp = client.post( - f"/api/contacts/{contact_id}/draft-reply", - headers={"X-CF-Tier": "free"}, - ) + resp = client.post(f"/api/contacts/{contact_id}/draft-reply") assert resp.status_code == 402 diff --git a/web/src/stores/messaging.ts b/web/src/stores/messaging.ts index df02a71..bc57765 100644 --- a/web/src/stores/messaging.ts +++ b/web/src/stores/messaging.ts @@ -133,6 +133,16 @@ export const useMessagingStore = defineStore('messaging', () => { return data.message_id } + async function updateMessageBody(id: number, body: string) { + const { data, error: fetchErr } = await useApiFetch( + `/api/messages/${id}`, + { method: 'PUT', body: JSON.stringify({ body }), headers: { 'Content-Type': 'application/json' } } + ) + if (fetchErr || !data) { error.value = 'Failed to save edits.'; return null } + messages.value = messages.value.map(m => m.id === id ? { ...m, body: data.body } : m) + return data + } + async function approveDraft(messageId: number): Promise { const { data, error: fetchErr } = await useApiFetch<{ body: string; approved_at: string }>( `/api/messages/${messageId}/approve`, @@ -159,6 +169,6 @@ export const useMessagingStore = defineStore('messaging', () => { messages, templates, loading, saving, error, draftPending, fetchMessages, fetchTemplates, createMessage, deleteMessage, createTemplate, updateTemplate, deleteTemplate, - requestDraft, approveDraft, clear, + requestDraft, approveDraft, updateMessageBody, clear, } }) diff --git a/web/src/views/MessagingView.vue b/web/src/views/MessagingView.vue index 15d0e88..1cc1753 100644 --- a/web/src/views/MessagingView.vue +++ b/web/src/views/MessagingView.vue @@ -40,21 +40,12 @@ - - Enable LLM drafts → -