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 bs4 import BeautifulSoup
from contextlib import asynccontextmanager 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 fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
@ -4195,6 +4195,10 @@ class MessageCreateBody(BaseModel):
logged_at: Optional[str] = None logged_at: Optional[str] = None
class MessageUpdateBody(BaseModel):
body: str
class TemplateCreateBody(BaseModel): class TemplateCreateBody(BaseModel):
title: str title: str
category: str = "custom" category: str = "custom"
@ -4216,7 +4220,7 @@ def get_messages(
job_id: Optional[int] = None, job_id: Optional[int] = None,
type: Optional[str] = None, type: Optional[str] = None,
direction: 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 from scripts.messaging import list_messages
return list_messages( return list_messages(
@ -4241,6 +4245,15 @@ def del_message(message_id: int):
raise HTTPException(404, "message not found") 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") @app.get("/api/message-templates")
def get_templates(): def get_templates():
from scripts.messaging import list_templates from scripts.messaging import list_templates
@ -4282,24 +4295,23 @@ def del_template(template_id: int):
# ── LLM Reply Draft (BSL 1.1) ───────────────────────────────────────────────── # ── LLM Reply Draft (BSL 1.1) ─────────────────────────────────────────────────
def _get_effective_tier(request: Request) -> str: def _get_effective_tier() -> str:
"""Resolve effective tier from request header or environment.""" """Resolve effective tier: Heimdall in cloud mode, APP_TIER env var in single-tenant."""
header_tier = request.headers.get("X-CF-Tier") if _CLOUD_MODE:
if header_tier: return _resolve_cloud_tier()
return header_tier
from app.wizard.tiers import effective_tier from app.wizard.tiers import effective_tier
return effective_tier() return effective_tier()
@app.post("/api/contacts/{contact_id}/draft-reply") @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.""" """Generate an LLM draft reply for an inbound job_contacts row. Tier-gated."""
from app.wizard.tiers import can_use, has_configured_llm from app.wizard.tiers import can_use, has_configured_llm
from scripts.messaging import create_message from scripts.messaging import create_message
from scripts.llm_reply_draft import generate_draft_reply from scripts.llm_reply_draft import generate_draft_reply
db_path = Path(_request_db.get() or DB_PATH) 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()): if not can_use(tier, "llm_reply_draft", has_byok=has_configured_llm()):
raise HTTPException(402, detail={"error": "tier_required", "min_tier": "free+byok"}) 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() con.commit()
finally: finally:
con.close() 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 # Ensure has_configured_llm returns False at both import locations
monkeypatch.setattr("app.wizard.tiers.has_configured_llm", lambda *a, **kw: False) 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) client = TestClient(dev_api.app)
resp = client.post( resp = client.post(f"/api/contacts/{contact_id}/draft-reply")
f"/api/contacts/{contact_id}/draft-reply",
headers={"X-CF-Tier": "free"},
)
assert resp.status_code == 402 assert resp.status_code == 402

View file

@ -133,6 +133,16 @@ export const useMessagingStore = defineStore('messaging', () => {
return data.message_id 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> { async function approveDraft(messageId: number): Promise<string | null> {
const { data, error: fetchErr } = await useApiFetch<{ body: string; approved_at: string }>( const { data, error: fetchErr } = await useApiFetch<{ body: string; approved_at: string }>(
`/api/messages/${messageId}/approve`, `/api/messages/${messageId}/approve`,
@ -159,6 +169,6 @@ export const useMessagingStore = defineStore('messaging', () => {
messages, templates, loading, saving, error, draftPending, messages, templates, loading, saving, error, draftPending,
fetchMessages, fetchTemplates, createMessage, deleteMessage, fetchMessages, fetchTemplates, createMessage, deleteMessage,
createTemplate, updateTemplate, deleteTemplate, 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="openLogModal('in_person')">Log note</button>
<button class="btn btn--ghost" @click="openTemplateModal('apply')">Use template</button> <button class="btn btn--ghost" @click="openTemplateModal('apply')">Use template</button>
<button <button
v-if="canDraftLlm"
class="btn btn--primary" class="btn btn--primary"
:disabled="store.loading" :disabled="store.loading"
@click="requestDraft" @click="requestDraft"
> >
{{ store.loading ? 'Drafting…' : 'Draft reply with LLM' }} {{ store.loading ? 'Drafting…' : 'Draft reply with LLM' }}
</button> </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 --> <!-- Osprey (Phase 2 stub) aria-disabled, never hidden -->
<button <button
@ -286,18 +277,8 @@ function updateDraftBody(id: number, value: string) {
// LLM draft + approval // LLM draft + approval
const canDraftLlm = ref(false)
const draftAnnouncement = ref('') 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() { async function requestDraft() {
// Find the most recent inbound job_contact for this job // Find the most recent inbound job_contact for this job
const inbound = jobContacts.value.find(c => c.direction === 'inbound') const inbound = jobContacts.value.find(c => c.direction === 'inbound')
@ -314,18 +295,18 @@ async function requestDraft() {
} }
async function approveDraft(messageId: number) { async function approveDraft(messageId: number) {
// Use the locally-edited body if the user changed it
const editedBody = draftBodyEdits.value[messageId] const editedBody = draftBodyEdits.value[messageId]
// Persist edits to DB before approving so history shows final version
if (editedBody !== undefined) { if (editedBody !== undefined) {
// Patch body via a NOOP approve server sets approved_at and returns body const updated = await store.updateMessageBody(messageId, editedBody)
// The edited body is in our local state; copy it to clipboard from local state if (!updated) return // error already set in store
} }
const body = await store.approveDraft(messageId) const body = await store.approveDraft(messageId)
if (body) { if (body) {
const finalBody = editedBody ?? body const finalBody = editedBody ?? body
await navigator.clipboard.writeText(finalBody) await navigator.clipboard.writeText(finalBody)
draftAnnouncement.value = 'Approved and copied to clipboard.' 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 // Lifecycle
onMounted(async () => { onMounted(async () => {
await Promise.all([loadJobs(), store.fetchTemplates(), checkLlmAvailable()]) await Promise.all([loadJobs(), store.fetchTemplates()])
}) })
onUnmounted(() => { onUnmounted(() => {
@ -489,10 +470,6 @@ onUnmounted(() => {
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-light); 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 { .btn--osprey {
opacity: 0.5; cursor: not-allowed; opacity: 0.5; cursor: not-allowed;
background: none; border: 1px dashed var(--color-border); background: none; border: 1px dashed var(--color-border);