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
This commit is contained in:
pyr0ball 2026-04-20 17:19:17 -07:00
parent 6812e3f9ef
commit 91e2faf5d0
5 changed files with 55 additions and 42 deletions

View file

@ -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"})

View file

@ -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()

View file

@ -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

View file

@ -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<Message>(
`/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<string | null> {
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,
}
})

View file

@ -40,21 +40,12 @@
<button class="btn btn--ghost" @click="openLogModal('in_person')">Log note</button>
<button class="btn btn--ghost" @click="openTemplateModal('apply')">Use template</button>
<button
v-if="canDraftLlm"
class="btn btn--primary"
:disabled="store.loading"
@click="requestDraft"
>
{{ store.loading ? 'Drafting…' : 'Draft reply with LLM' }}
</button>
<a
v-else-if="!canDraftLlm"
href="#/settings/connections"
class="btn-enable-llm"
title="Configure an LLM backend to enable AI reply drafting"
>
Enable LLM drafts
</a>
<!-- Osprey (Phase 2 stub) aria-disabled, never hidden -->
<button
@ -286,18 +277,8 @@ function updateDraftBody(id: number, value: string) {
// LLM draft + approval
const canDraftLlm = ref(false)
const draftAnnouncement = ref('')
async function checkLlmAvailable() {
await useApiFetch<{ available: boolean }>('/api/vision/health')
// Re-use vision health route as a proxy for LLM config replace with a dedicated
// /api/llm/health endpoint if one is added in future.
// For now: canDraftLlm is set based on whether user has any configured LLM
// (the server will 402 if not the button is just a hint, not a gate)
canDraftLlm.value = true // Always show; server enforces the real gate
}
async function requestDraft() {
// Find the most recent inbound job_contact for this job
const inbound = jobContacts.value.find(c => c.direction === 'inbound')
@ -314,18 +295,18 @@ async function requestDraft() {
}
async function approveDraft(messageId: number) {
// Use the locally-edited body if the user changed it
const editedBody = draftBodyEdits.value[messageId]
// Persist edits to DB before approving so history shows final version
if (editedBody !== undefined) {
// Patch body via a NOOP approve server sets approved_at and returns body
// The edited body is in our local state; copy it to clipboard from local state
const updated = await store.updateMessageBody(messageId, editedBody)
if (!updated) return // error already set in store
}
const body = await store.approveDraft(messageId)
if (body) {
const finalBody = editedBody ?? body
await navigator.clipboard.writeText(finalBody)
draftAnnouncement.value = 'Approved and copied to clipboard.'
setTimeout(() => { draftAnnouncement.value = '' }, 3000 )
setTimeout(() => { draftAnnouncement.value = '' }, 3000)
}
}
@ -423,7 +404,7 @@ function formatTime(iso: string): string {
// Lifecycle
onMounted(async () => {
await Promise.all([loadJobs(), store.fetchTemplates(), checkLlmAvailable()])
await Promise.all([loadJobs(), store.fetchTemplates()])
})
onUnmounted(() => {
@ -489,10 +470,6 @@ onUnmounted(() => {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-light);
}
.btn-enable-llm {
font-size: var(--text-sm); color: var(--app-primary);
text-decoration: none; padding: var(--space-2) var(--space-3);
}
.btn--osprey {
opacity: 0.5; cursor: not-allowed;
background: none; border: 1px dashed var(--color-border);