mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
feat(coach): attach multiple documents to a conversation
Replace the scalar document_id discriminator on a coach conversation with a CoachConversationDocument join table and a sticky documentScoped flag, so a conversation can be grounded on several stored documents at once while the prompt-injection fence stays intact across the data-model change. The fence is re-established at both enforcement points, fail-closed: - Client: resolveCoachSendTarget routes any turn on a fenced conversation (or a fresh chat with staged attachments) to the new /api/insights/chat/fenced endpoint; the fenced body is strict and never carries scope, guided-question, or prefill hints. - Server: the tool route fetches with documentScoped:false AND 404s when the fetched row still carries an attachment (flag/join drift), emitting a fence_drift audit alarm. Document text now enters an LLM prompt in exactly one code path — the fenced pipeline — with no tools and no health snapshot; a module-graph test pins that the tool loop is unreachable from it. The fenced turn fences each document separately with a server-built, scrubbed header line (title and filename are untrusted text), runs a per-document egress consent check on the single picked provider, and grounds numerically over the live attachment union only. Attachments are capped at five documents and 192 KB of combined text with honest truncation. Attach and detach ride dedicated endpoints: attaching validates the document is owned, live, indexed, and within cap, then flips the conversation fenced (the one legal, privilege-reducing transition, audit-logged); detaching removes the document but never clears the flag. The document-sheet chat keeps its public wire contract and shares the same fenced pipeline over the join. Migration 0245 adds the join table and the flag, backfills every legacy single-document chat into a one-attachment fenced conversation, and drops the scalar column in the same transaction — no dual-truth window.
This commit is contained in:
1 parent
d72f078764
commit
502783a3ea
45 files changed
+4541
-861
No files matched your search
@@ -187,6 +187,8 @@ beforeEach(() => {
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
messageCount: 0,
|
||||
fenced: true,
|
||||
attachments: [],
|
||||
});
|
||||
vi.mocked(appendMessage).mockImplementation(
|
||||
async (p) =>
|
||||
@@ -328,19 +330,19 @@ describe("POST — security model (no tools, no snapshot, fenced)", () => {
|
||||
});
|
||||
|
||||
describe("POST — conversation surface isolation", () => {
|
||||
it("404s continuing a conversationId not scoped to this document, fetched with documentId", async () => {
|
||||
it("404s continuing a conversationId that does not hold this document (path-id join filter)", async () => {
|
||||
vi.mocked(fetchConversationWithMessages).mockResolvedValue(null);
|
||||
const res = await POST(
|
||||
req("doc-1", { conversationId: "other-conv", message: "hi" }) as never,
|
||||
ctx("doc-1") as never,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
// The fetch is document-scoped — a Coach thread (documentId null) or a
|
||||
// foreign document's thread can never be loaded here.
|
||||
// The fetch requires a LIVE join row for the path id (+ documentScoped) — a
|
||||
// health thread or a foreign/other document's thread can never load here.
|
||||
expect(fetchConversationWithMessages).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"other-conv",
|
||||
{ documentId: "doc-1" },
|
||||
{ attachedDocumentId: "doc-1" },
|
||||
);
|
||||
expect(runStreamingRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
/**
|
||||
* v1.27.33 (Document vault P4) — chat about ONE stored document.
|
||||
* v1.27.33 (Document vault P4) / v1.29.x (S7) — chat about a document from the
|
||||
* document SHEET (path-keyed, single document).
|
||||
*
|
||||
* POST streams a grounded prose reply about a single document's text as
|
||||
* Server-Sent Events; GET reads the document's chat history. The conversation
|
||||
* persists (encrypted) as a `CoachConversation` with `documentId` set, reusing
|
||||
* the entire Coach message store, so re-opening the document shows the thread.
|
||||
* POST streams a grounded prose reply as Server-Sent Events; GET reads the
|
||||
* document's chat history. This route keeps its PUBLIC OpenAPI wire contract (the
|
||||
* iOS client may hold it) but is reimplemented over the S7 data model: a
|
||||
* sheet-started chat IS a one-attachment FENCED conversation
|
||||
* (`CoachConversation.documentScoped = true` + a `CoachConversationDocument` join
|
||||
* row for this document). The whole turn pipeline is shared with
|
||||
* `POST /api/insights/chat/fenced` via `@/lib/documents/fenced-chat`, so the two
|
||||
* cannot drift and the fence posture is identical.
|
||||
*
|
||||
* SECURITY MODEL (the heart of this feature — untrusted document text enters an
|
||||
* LLM prompt):
|
||||
* - NO TOOLS. A single completion per turn, never the Coach tool loop. There
|
||||
* is nothing an injected instruction could DO — no write, no other resource.
|
||||
* - NO HEALTH SNAPSHOT (research D3). The ONLY context is this one document's
|
||||
* text; the user's health record is never injected.
|
||||
* - PROMPT-INJECTION FENCING. The document text is fenced as DATA via
|
||||
* `fenceDocument` (marker pair + marker-scrubbing) inside the system prompt,
|
||||
* which states the fenced content is a document to answer questions about,
|
||||
* not instructions to follow. `detectRefusal` guards the inbound message and
|
||||
* REPLAYS over every prior user turn on reload; `screenCoachReply` guards the
|
||||
* outbound reply (dose-prescription / fabricated-risk-score).
|
||||
* - NUMERIC GROUNDING. `findUnverifiedCoachNumbers`, pointed at the document's
|
||||
* own numbers as the authoritative set, strips any figure the model invents.
|
||||
* - CONSENT + BUDGET + RATE. `assertDocumentEgressConsent` (the document is
|
||||
* sent to the provider) on the document-ordered pick (local-first, codex
|
||||
* last), the shared AI budget ledger, and a per-user `document-chat` rate
|
||||
* bucket. Answers render as PLAIN React text on the client (no markdown lib).
|
||||
* - INDEXED DOCUMENTS ONLY. The chat is available only for a document that has
|
||||
* been content-indexed (its text is the grounding); 422 otherwise.
|
||||
* If the user later opens the thread in the coach and attaches a SECOND document,
|
||||
* this path-keyed POST still resolves the conversation (it still holds a join row
|
||||
* for the path id) and the reply grounds on ALL of its live attachments — one
|
||||
* conversation object, consistent everywhere.
|
||||
*
|
||||
* SECURITY MODEL: see `fenced-chat.ts` — NO tools, NO health snapshot, every
|
||||
* document fenced as untrusted DATA, per-document egress consent, numeric
|
||||
* grounding over the live attachment union. Available only for a content-indexed
|
||||
* document (422 otherwise).
|
||||
*/
|
||||
import { type NextRequest } from "next/server";
|
||||
|
||||
@@ -36,38 +30,12 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { createSseStream } from "@/lib/sse/create-stream";
|
||||
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
import {
|
||||
AllProvidersFailedError,
|
||||
runStreamingRawCompletionWithFallback,
|
||||
} from "@/lib/ai/provider-runner";
|
||||
import { singleUserTurn } from "@/lib/ai/types";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveDailyCap,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { detectRefusal } from "@/lib/ai/coach/refusal";
|
||||
import {
|
||||
screenCoachReply,
|
||||
coachOutboundFallback,
|
||||
} from "@/lib/ai/coach/outbound-guard";
|
||||
import {
|
||||
findUnverifiedCoachNumbers,
|
||||
stripUnverifiedNumbers,
|
||||
} from "@/lib/ai/coach/coach-prose-grounding";
|
||||
import {
|
||||
appendMessage,
|
||||
createConversation,
|
||||
fetchConversationWithMessages,
|
||||
listConversations,
|
||||
} from "@/lib/ai/coach/persistence";
|
||||
import type { CoachStreamEvent } from "@/lib/ai/coach/types";
|
||||
|
||||
import {
|
||||
DOCUMENT_CHAT_BUCKET,
|
||||
DOCUMENT_CHAT_LIMIT_PER_MINUTE,
|
||||
@@ -75,8 +43,13 @@ import {
|
||||
loadOwnedDocument,
|
||||
} from "@/lib/documents/ai-route-support";
|
||||
import { loadDocumentChatText } from "@/lib/documents/content-index";
|
||||
import { buildDocumentChatSystemPrompt } from "@/lib/documents/document-chat-prompt";
|
||||
import { resolveDocumentTextProvider } from "@/lib/documents/provider-order";
|
||||
import {
|
||||
loadFencedDocuments,
|
||||
screenFencedInbound,
|
||||
streamFencedReply,
|
||||
streamFencedRefusal,
|
||||
type FencedTurn,
|
||||
} from "@/lib/documents/fenced-chat";
|
||||
import {
|
||||
documentChatHistoryQuerySchema,
|
||||
documentChatRequestSchema,
|
||||
@@ -87,49 +60,6 @@ export const runtime = "nodejs";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
const SSE_HEADERS: Record<string, string> = {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
};
|
||||
|
||||
/** Keepalive comment frame so a reverse proxy never idle-drops a slow backend. */
|
||||
const HEARTBEAT_MS = 12_000;
|
||||
const HEARTBEAT_FRAME = new TextEncoder().encode(": ka\n\n");
|
||||
|
||||
/** Prior turns kept verbatim in the per-call window — document chats are short. */
|
||||
const HISTORY_TURN_CAP = 20;
|
||||
|
||||
function encodeFrame(event: CoachStreamEvent): Uint8Array {
|
||||
return new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`);
|
||||
}
|
||||
|
||||
/** Split a full reply into ~word-sized chunks for a streaming feel. */
|
||||
function tokeniseForStreaming(content: string): string[] {
|
||||
if (!content) return [];
|
||||
const matches = content.match(/\S+\s*/g);
|
||||
return matches ?? [content];
|
||||
}
|
||||
|
||||
/** Yield to the event loop so each SSE frame flushes as its own chunk. */
|
||||
function flushTick(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
/** A standalone SSE stream carrying a single structured error frame (HTTP 200). */
|
||||
function streamError(code: string): Response {
|
||||
const stream = createSseStream((controller) => {
|
||||
controller.enqueue(encodeFrame({ type: "error", code, message: code }));
|
||||
});
|
||||
return new Response(stream, { status: 200, headers: SSE_HEADERS });
|
||||
}
|
||||
|
||||
interface Turn {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
// ─── POST — send a turn, stream the reply ───────────────────────────────────
|
||||
|
||||
export const POST = apiHandler(
|
||||
@@ -149,7 +79,7 @@ export const POST = apiHandler(
|
||||
}
|
||||
|
||||
// The chat is available ONLY for a content-indexed document — its text is
|
||||
// the sole grounding. Prefer the verbatim capture; fall back to normalised.
|
||||
// the grounding. Keep the public 422 wire code for this path.
|
||||
const context = await loadDocumentChatText(userId, id);
|
||||
if (!context) {
|
||||
return apiError("This document has not been indexed for chat yet.", 422, {
|
||||
@@ -169,7 +99,6 @@ export const POST = apiHandler(
|
||||
}
|
||||
const { conversationId, message, locale: bodyLocale } = parsed.data;
|
||||
|
||||
// Per-user request-rate ceiling in front of the daily budget gate.
|
||||
const rl = await checkRateLimit(
|
||||
`${DOCUMENT_CHAT_BUCKET}:${userId}`,
|
||||
DOCUMENT_CHAT_LIMIT_PER_MINUTE,
|
||||
@@ -192,409 +121,91 @@ export const POST = apiHandler(
|
||||
});
|
||||
const contractLocale = locale === "de" ? "de" : "en";
|
||||
|
||||
// ── Inbound refusal / injection guard ──────────────────────────────
|
||||
const refusal = detectRefusal({ message, locale, defaultAllow: true });
|
||||
if (refusal.refuse && refusal.message) {
|
||||
annotate({
|
||||
action: { name: "documents.chat.refused" },
|
||||
meta: { reason: refusal.reason },
|
||||
});
|
||||
return streamRefusal({
|
||||
userId,
|
||||
documentId: id,
|
||||
conversationId,
|
||||
message,
|
||||
refusalText: refusal.message,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Conversation resolution (document-scoped) ──────────────────────
|
||||
// ── Conversation resolution (fenced, this-document-scoped) ─────────────
|
||||
let workingConversationId: string;
|
||||
let priorTurns: Turn[] = [];
|
||||
let documentIds: string[];
|
||||
let priorTurns: FencedTurn[] = [];
|
||||
if (conversationId) {
|
||||
// Must be a FENCED conversation that actually holds THIS document (a join
|
||||
// row for the path id). A thread for a DIFFERENT document 404s.
|
||||
const existing = await fetchConversationWithMessages(
|
||||
userId,
|
||||
conversationId,
|
||||
{ documentId: id },
|
||||
{ attachedDocumentId: id },
|
||||
);
|
||||
if (!existing) {
|
||||
// 404, not 403 — never reveal cross-user / cross-document existence.
|
||||
throw new HttpError(404, "documents.chat.conversationNotFound");
|
||||
}
|
||||
workingConversationId = existing.id;
|
||||
// Ground on ALL of the thread's live attachments, not only the path doc.
|
||||
documentIds = (existing.attachments ?? []).map((a) => a.documentId);
|
||||
priorTurns = existing.messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
// Replay-injection guard: an injection that slipped past the regex bank
|
||||
// on an earlier turn would re-enter the prompt every reply. Re-run the
|
||||
// detector over every prior user turn; on a hit, refuse.
|
||||
for (let i = 0; i < priorTurns.length; i++) {
|
||||
const turn = priorTurns[i];
|
||||
if (turn.role !== "user") continue;
|
||||
const replayed = detectRefusal({ message: turn.content, locale });
|
||||
if (!replayed.refuse) continue;
|
||||
annotate({
|
||||
action: { name: "documents.chat.replay_injection" },
|
||||
meta: { reason: replayed.reason, turnIndex: i },
|
||||
});
|
||||
await auditLog("documents.chat.replay_injection", {
|
||||
userId,
|
||||
details: {
|
||||
conversationId: existing.id,
|
||||
documentId: id,
|
||||
turnIndex: i,
|
||||
reason: replayed.reason,
|
||||
},
|
||||
});
|
||||
return streamRefusal({
|
||||
userId,
|
||||
documentId: id,
|
||||
conversationId: existing.id,
|
||||
message,
|
||||
refusalText:
|
||||
replayed.message ??
|
||||
(locale === "de"
|
||||
? "Eine frühere Nachricht in dieser Unterhaltung enthält Anweisungen, die meine Vorgaben überschreiben sollen. Beginne bitte eine neue Unterhaltung."
|
||||
: "An earlier message in this conversation contains wording that overrides my instructions. Please start a new conversation."),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const created = await createConversation({
|
||||
userId,
|
||||
title: message,
|
||||
documentId: id,
|
||||
documentScoped: true,
|
||||
attachmentIds: [id],
|
||||
});
|
||||
workingConversationId = created.id;
|
||||
documentIds = [id];
|
||||
}
|
||||
|
||||
// ── Provider resolution (document order: local-first, codex last) ──
|
||||
// No vision needed — the chat grounds on the STORED TEXT, not the image, so
|
||||
// ANY configured provider can chat. A single pick (no runner cascade) so the
|
||||
// exact egress equals the picked provider that `assertDocumentEgressConsent`
|
||||
// gates.
|
||||
const { pick } = await resolveDocumentTextProvider(userId);
|
||||
if (!pick) {
|
||||
annotate({ action: { name: "documents.chat.noProvider" } });
|
||||
return streamError("documents.chat.provider.none");
|
||||
}
|
||||
// Consent for the document leaving the machine to a third-party AI. A local
|
||||
// pick stays ungated; any external pick needs an active receipt (403).
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
// Persist the user's turn first so it's on disk regardless of the outcome.
|
||||
await appendMessage({
|
||||
conversationId: workingConversationId,
|
||||
role: "user",
|
||||
content: message,
|
||||
});
|
||||
|
||||
// ── Budget reservation (atomic, before the provider call) ──────────
|
||||
const dateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.documentChat.maxTokens,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
annotate({ action: { name: "documents.chat.budget.exceeded" } });
|
||||
return streamError("documents.chat.budget.exceeded");
|
||||
}
|
||||
|
||||
// ── Build the lean prompt: system (persona + safety + FENCED document,
|
||||
// NO health snapshot) + the conversation transcript. ──────────────
|
||||
const systemPrompt = buildDocumentChatSystemPrompt(
|
||||
contractLocale,
|
||||
context.text,
|
||||
);
|
||||
const window: Turn[] = [
|
||||
...priorTurns.slice(-HISTORY_TURN_CAP),
|
||||
{ role: "user", content: message },
|
||||
];
|
||||
const transcript = window
|
||||
.map((t) => `${t.role.toUpperCase()}: ${t.content}`)
|
||||
.join("\n\n");
|
||||
const userPrompt = `CONVERSATION
|
||||
${transcript}
|
||||
|
||||
Reply now as the assistant, grounded ONLY in the document above, in ${
|
||||
contractLocale === "de" ? "German" : "English"
|
||||
}.`;
|
||||
|
||||
type Outcome =
|
||||
| {
|
||||
ok: true;
|
||||
replyText: string;
|
||||
messageId: string;
|
||||
totalTokens: number;
|
||||
model: string | null;
|
||||
}
|
||||
| { ok: false; code: string };
|
||||
|
||||
async function produceReply(): Promise<Outcome> {
|
||||
let result;
|
||||
try {
|
||||
const fallback = await runStreamingRawCompletionWithFallback({
|
||||
userId,
|
||||
// Single-provider — the document-ordered pick, no cascade.
|
||||
providers: [pick!.entry],
|
||||
onDelta: () => {},
|
||||
params: singleUserTurn({
|
||||
system: systemPrompt,
|
||||
user: userPrompt,
|
||||
temperature: AI_BUDGETS.documentChat.temperature,
|
||||
maxTokens: AI_BUDGETS.documentChat.maxTokens,
|
||||
signal: request.signal,
|
||||
}),
|
||||
});
|
||||
result = fallback.result;
|
||||
} catch (err) {
|
||||
// Provider failed — no tokens billed; refund the full reservation.
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey).catch(
|
||||
() => {},
|
||||
);
|
||||
if (err instanceof AllProvidersFailedError) {
|
||||
const allRateLimited =
|
||||
err.attempts.length > 0 &&
|
||||
err.attempts.every((a) => a.httpStatus === 429);
|
||||
annotate({
|
||||
action: { name: "documents.chat.providerFailed" },
|
||||
meta: {
|
||||
attempts: err.attempts.length,
|
||||
credentialExpired: err.primaryCredentialExpired,
|
||||
},
|
||||
});
|
||||
if (err.primaryCredentialExpired) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "documents.chat.provider.credential_expired",
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
code: allRateLimited
|
||||
? "documents.chat.provider.rate_limited"
|
||||
: "documents.chat.provider.unavailable",
|
||||
};
|
||||
}
|
||||
// ── Inbound refusal / replay-injection guard ───────────────────────────
|
||||
const screen = screenFencedInbound({ message, priorTurns, locale });
|
||||
if (screen.refuse) {
|
||||
if (screen.replayTurnIndex !== null) {
|
||||
annotate({
|
||||
action: { name: "documents.chat.providerFailed" },
|
||||
meta: { attempts: 1, unwrapped: true },
|
||||
action: { name: "documents.chat.replay_injection" },
|
||||
meta: { reason: screen.reason, turnIndex: screen.replayTurnIndex },
|
||||
});
|
||||
return { ok: false, code: "documents.chat.provider.unavailable" };
|
||||
}
|
||||
|
||||
const totalTokens = result.tokensUsed ?? 0;
|
||||
const cachedTokens = result.cachedInputTokens ?? 0;
|
||||
// Tokens were billed regardless of reply quality — reconcile now.
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
reservation.reserved,
|
||||
totalTokens,
|
||||
dateKey,
|
||||
cachedTokens,
|
||||
).catch(() => {});
|
||||
|
||||
let replyText = (result.content ?? "").trim();
|
||||
if (!replyText)
|
||||
return { ok: false, code: "documents.chat.provider.empty" };
|
||||
|
||||
// ── Outbound safety screen (dose-prescription / fabricated risk) ──
|
||||
const outbound = screenCoachReply(replyText);
|
||||
if (outbound.block && outbound.reason) {
|
||||
replyText = coachOutboundFallback(outbound.reason, locale);
|
||||
annotate({
|
||||
action: { name: "documents.chat.outbound_blocked" },
|
||||
meta: { reason: outbound.reason },
|
||||
});
|
||||
await auditLog("documents.chat.outbound_blocked", {
|
||||
await auditLog("documents.chat.replay_injection", {
|
||||
userId,
|
||||
details: {
|
||||
conversationId: workingConversationId,
|
||||
reason: outbound.reason,
|
||||
documentId: id,
|
||||
turnIndex: screen.replayTurnIndex,
|
||||
reason: screen.reason,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
annotate({
|
||||
action: { name: "documents.chat.refused" },
|
||||
meta: { reason: screen.reason },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Numeric grounding — authoritative set = the DOCUMENT's numbers ──
|
||||
// A blocked turn already carries canned fallback prose, so skip it.
|
||||
if (!outbound.block) {
|
||||
const unverified = findUnverifiedCoachNumbers(replyText, [
|
||||
context!.text,
|
||||
]);
|
||||
if (unverified.length > 0) {
|
||||
const { prose, stripped } = stripUnverifiedNumbers(
|
||||
replyText,
|
||||
unverified,
|
||||
);
|
||||
replyText = prose;
|
||||
annotate({
|
||||
action: { name: "documents.chat.number_unverified" },
|
||||
meta: {
|
||||
flagged: unverified.length,
|
||||
stripped,
|
||||
tokens: unverified.slice(0, 6).map((u) => u.source),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the assistant turn BEFORE streaming; a disconnect still leaves
|
||||
// the canonical row on disk.
|
||||
const assistant = await appendMessage({
|
||||
return streamFencedRefusal({
|
||||
conversationId: workingConversationId,
|
||||
role: "assistant",
|
||||
content: replyText,
|
||||
providerType: pick!.providerType,
|
||||
tokensUsed: totalTokens || null,
|
||||
model: result.model ?? null,
|
||||
message,
|
||||
refusalText: screen.refusalText,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.chat.replied" },
|
||||
meta: {
|
||||
provider: pick!.providerType,
|
||||
tokens: totalTokens,
|
||||
conversationId: workingConversationId,
|
||||
documentId: id,
|
||||
historyTurns: window.length,
|
||||
textSource: context!.source,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
replyText,
|
||||
messageId: assistant.id,
|
||||
totalTokens,
|
||||
model: result.model ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Stream the body ────────────────────────────────────────────────
|
||||
const stream = createSseStream(async (controller) => {
|
||||
const heartbeat = setInterval(() => {
|
||||
controller.enqueue(HEARTBEAT_FRAME);
|
||||
}, HEARTBEAT_MS);
|
||||
// ── Load the live attachment contexts (owner-scoped, indexed-only) ─────
|
||||
const loaded = await loadFencedDocuments(userId, documentIds);
|
||||
if (!loaded.ok) {
|
||||
return apiError("An attached document is unavailable", 422, {
|
||||
errorCode: "coach.fenced.attachmentUnavailable",
|
||||
unavailableDocumentId: loaded.unavailableDocId,
|
||||
});
|
||||
}
|
||||
|
||||
let outcome: Outcome;
|
||||
try {
|
||||
outcome = await produceReply();
|
||||
} catch (err) {
|
||||
clearInterval(heartbeat);
|
||||
annotate({
|
||||
action: { name: "documents.chat.streamError" },
|
||||
meta: { message: err instanceof Error ? err.name : "unknown" },
|
||||
});
|
||||
if (!controller.signal.aborted) {
|
||||
controller.enqueue(
|
||||
encodeFrame({
|
||||
type: "error",
|
||||
code: "documents.chat.provider.unavailable",
|
||||
message: "documents.chat.provider.unavailable",
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
clearInterval(heartbeat);
|
||||
|
||||
if (!outcome.ok) {
|
||||
if (!controller.signal.aborted) {
|
||||
controller.enqueue(
|
||||
encodeFrame({
|
||||
type: "error",
|
||||
code: outcome.code,
|
||||
message: outcome.code,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const tok of tokeniseForStreaming(outcome.replyText)) {
|
||||
if (controller.signal.aborted) return;
|
||||
controller.enqueue(encodeFrame({ type: "token", token: tok }));
|
||||
await flushTick();
|
||||
}
|
||||
if (controller.signal.aborted) return;
|
||||
controller.enqueue(
|
||||
encodeFrame({
|
||||
type: "done",
|
||||
conversationId: workingConversationId,
|
||||
messageId: outcome.messageId,
|
||||
usage: {
|
||||
totalTokens: outcome.totalTokens || null,
|
||||
model: outcome.model,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return streamFencedReply({
|
||||
userId,
|
||||
conversationId: workingConversationId,
|
||||
docs: loaded.docs,
|
||||
priorTurns,
|
||||
message,
|
||||
contractLocale,
|
||||
locale,
|
||||
signal: request.signal,
|
||||
});
|
||||
|
||||
return new Response(stream, { status: 200, headers: SSE_HEADERS });
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Emit a refusal as a single `token` frame + `done`. No provider call, no
|
||||
* persisted assistant message — the user message is kept so the rail shows the
|
||||
* attempt. Mirrors the Coach's `streamRefusal`, document-scoped.
|
||||
*/
|
||||
async function streamRefusal(args: {
|
||||
userId: string;
|
||||
documentId: string;
|
||||
conversationId: string | undefined;
|
||||
message: string;
|
||||
refusalText: string;
|
||||
}): Promise<Response> {
|
||||
let conversationId = args.conversationId;
|
||||
if (!conversationId) {
|
||||
const created = await createConversation({
|
||||
userId: args.userId,
|
||||
title: args.message,
|
||||
documentId: args.documentId,
|
||||
});
|
||||
conversationId = created.id;
|
||||
} else {
|
||||
const owned = await fetchConversationWithMessages(
|
||||
args.userId,
|
||||
conversationId,
|
||||
{ documentId: args.documentId },
|
||||
);
|
||||
if (!owned) throw new HttpError(404, "documents.chat.conversationNotFound");
|
||||
}
|
||||
|
||||
await appendMessage({
|
||||
conversationId,
|
||||
role: "user",
|
||||
content: args.message,
|
||||
});
|
||||
const refusalMessage = await appendMessage({
|
||||
conversationId,
|
||||
role: "assistant",
|
||||
content: args.refusalText,
|
||||
providerType: "refusal",
|
||||
});
|
||||
|
||||
const stream = createSseStream((controller) => {
|
||||
controller.enqueue(encodeFrame({ type: "token", token: args.refusalText }));
|
||||
controller.enqueue(
|
||||
encodeFrame({
|
||||
type: "done",
|
||||
conversationId: conversationId!,
|
||||
messageId: refusalMessage.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
return new Response(stream, { status: 200, headers: SSE_HEADERS });
|
||||
}
|
||||
|
||||
// ─── GET — the document's chat history ──────────────────────────────────────
|
||||
|
||||
export const GET = apiHandler(
|
||||
@@ -625,12 +236,12 @@ export const GET = apiHandler(
|
||||
});
|
||||
}
|
||||
|
||||
// With a conversationId → that one thread's messages (document-scoped).
|
||||
// With a conversationId → that one thread's messages (must hold this document).
|
||||
if (parsed.data.conversationId) {
|
||||
const detail = await fetchConversationWithMessages(
|
||||
userId,
|
||||
parsed.data.conversationId,
|
||||
{ documentId: id },
|
||||
{ attachedDocumentId: id },
|
||||
);
|
||||
if (!detail) {
|
||||
return apiError("Conversation not found", 404, {
|
||||
@@ -647,7 +258,7 @@ export const GET = apiHandler(
|
||||
// Otherwise → the paginated list of this document's chat threads.
|
||||
const page = await listConversations({
|
||||
userId,
|
||||
documentId: id,
|
||||
attachedDocumentId: id,
|
||||
cursor: parsed.data.cursor,
|
||||
limit: parsed.data.limit,
|
||||
});
|
||||
|
||||
@@ -112,6 +112,17 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray<string> = [
|
||||
"src/app/api/insights/health-status/route.ts",
|
||||
"src/app/api/insights/breathing-screening/route.ts",
|
||||
"src/app/api/insights/labs-changes/route.ts",
|
||||
// v1.29.x (S7) — the FENCED multi-document coach chat + its attach/detach
|
||||
// mutations. These live under /insights/chat for URL grouping but are
|
||||
// DOCUMENT-surface routes: they gate on the `inboundDocuments` module
|
||||
// (`requireModuleEnabled`), NOT on the Coach assistant surface — parity with
|
||||
// the document-sheet chat (`/api/documents/inbound/[id]/chat`, outside this
|
||||
// scan). The security boundary here is the fence (no tools, no snapshot),
|
||||
// not the Coach gate; disabling Coach must not wedge a fenced document chat
|
||||
// any more than it wedges the document sheet. See the S7 design §3.2 step 1.
|
||||
"src/app/api/insights/chat/fenced/route.ts",
|
||||
"src/app/api/insights/chat/[id]/attachments/route.ts",
|
||||
"src/app/api/insights/chat/[id]/attachments/[documentId]/route.ts",
|
||||
];
|
||||
|
||||
const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")';
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* v1.29.x (S7) — detach a document from a coach conversation.
|
||||
*
|
||||
* DELETE removes the join row. It writes NO flag: a detached conversation stays
|
||||
* FENCED (the sticky-flag invariant — a thread whose history may contain
|
||||
* document-derived text must never regain the tool loop). The absence of any
|
||||
* `documentScoped` write in `detachDocument` is the guarantee, not a guarded
|
||||
* branch. Detaching the last pill leaves a fenced conversation with zero
|
||||
* attachments; the fenced endpoint still serves it (the coach honestly says it
|
||||
* has no document to read from, and the grounding union is empty).
|
||||
*/
|
||||
import { type NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
import {
|
||||
detachDocument,
|
||||
loadConversationAttachmentDTOs,
|
||||
} from "@/lib/ai/coach/persistence";
|
||||
import {
|
||||
DOCUMENT_CHAT_BUCKET,
|
||||
DOCUMENT_CHAT_LIMIT_PER_MINUTE,
|
||||
DOCUMENT_CHAT_WINDOW_MS,
|
||||
} from "@/lib/documents/ai-route-support";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string; documentId: string }> };
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const userId = user.id;
|
||||
|
||||
const gate = await requireModuleEnabled(userId, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { id: conversationId, documentId } = await params;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`${DOCUMENT_CHAT_BUCKET}:${userId}`,
|
||||
DOCUMENT_CHAT_LIMIT_PER_MINUTE,
|
||||
DOCUMENT_CHAT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests. Try again later.", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
});
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const removed = await detachDocument({
|
||||
userId,
|
||||
conversationId,
|
||||
documentId,
|
||||
});
|
||||
if (!removed) {
|
||||
// Missing row or foreign conversation → 404, no info leak.
|
||||
return apiError("Attachment not found", 404, {
|
||||
errorCode: "coach.fenced.attachmentNotFound",
|
||||
});
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.attachments.removed" },
|
||||
meta: { conversationId, documentId },
|
||||
});
|
||||
|
||||
const attachments = await loadConversationAttachmentDTOs(conversationId);
|
||||
// `fenced` stays true — the conversation is permanently fenced.
|
||||
return apiSuccess({ attachments, fenced: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,213 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.29.x (S7) — attach / detach routes (design §4.2, adversarial tests 9, 11,
|
||||
* 22 + the tool→fenced flip audit + the detach-writes-no-flag invariant).
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/persistence", () => ({
|
||||
fetchConversationAttachmentState: vi.fn(),
|
||||
attachDocument: vi.fn().mockResolvedValue(undefined),
|
||||
detachDocument: vi.fn(),
|
||||
loadConversationAttachmentDTOs: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
vi.mock("@/lib/documents/attach-validate", () => ({
|
||||
validateAttachmentCandidate: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { DELETE } from "../[documentId]/route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import {
|
||||
attachDocument,
|
||||
detachDocument,
|
||||
fetchConversationAttachmentState,
|
||||
} from "@/lib/ai/coach/persistence";
|
||||
import { validateAttachmentCandidate } from "@/lib/documents/attach-validate";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "t", role: "USER" as const, locale: "en" },
|
||||
};
|
||||
|
||||
const postReq = (body: unknown) =>
|
||||
new NextRequest(
|
||||
new URL("http://localhost/api/insights/chat/conv-1/attachments"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
const postCtx = (id = "conv-1") => ({ params: Promise.resolve({ id }) });
|
||||
const delCtx = (id = "conv-1", documentId = "doc-a") => ({
|
||||
params: Promise.resolve({ id, documentId }),
|
||||
});
|
||||
|
||||
async function code(res: Response): Promise<string | undefined> {
|
||||
const b = await res.json();
|
||||
return b.meta?.errorCode ?? b.errorCode;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(validateAttachmentCandidate).mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
describe("POST attach", () => {
|
||||
it("404s a foreign / unknown conversation (no cross-user leak)", async () => {
|
||||
vi.mocked(fetchConversationAttachmentState).mockResolvedValue(null);
|
||||
const res = await POST(
|
||||
postReq({ documentId: "doc-a" }) as never,
|
||||
postCtx() as never,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(attachDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects the tool-mode/unknown body field via .strict()", async () => {
|
||||
vi.mocked(fetchConversationAttachmentState).mockResolvedValue({
|
||||
id: "conv-1",
|
||||
documentScoped: true,
|
||||
messageCount: 0,
|
||||
attachmentIds: [],
|
||||
});
|
||||
const res = await POST(
|
||||
postReq({ documentId: "doc-a", scope: "x" }) as never,
|
||||
postCtx() as never,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("is idempotent for an already-attached document (200, no re-validate)", async () => {
|
||||
vi.mocked(fetchConversationAttachmentState).mockResolvedValue({
|
||||
id: "conv-1",
|
||||
documentScoped: true,
|
||||
messageCount: 3,
|
||||
attachmentIds: ["doc-a"],
|
||||
});
|
||||
const res = await POST(
|
||||
postReq({ documentId: "doc-a" }) as never,
|
||||
postCtx() as never,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(validateAttachmentCandidate).not.toHaveBeenCalled();
|
||||
expect(attachDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s (cap) when the candidate is rejected by validation", async () => {
|
||||
vi.mocked(fetchConversationAttachmentState).mockResolvedValue({
|
||||
id: "conv-1",
|
||||
documentScoped: true,
|
||||
messageCount: 0,
|
||||
attachmentIds: ["a", "b", "c", "d", "e"],
|
||||
});
|
||||
vi.mocked(validateAttachmentCandidate).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 422,
|
||||
errorCode: "coach.fenced.attachmentLimit",
|
||||
});
|
||||
const res = await POST(
|
||||
postReq({ documentId: "doc-f" }) as never,
|
||||
postCtx() as never,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(await code(res)).toBe("coach.fenced.attachmentLimit");
|
||||
});
|
||||
|
||||
it("attaches to a fenced thread WITHOUT a conversion audit (already fenced)", async () => {
|
||||
vi.mocked(fetchConversationAttachmentState).mockResolvedValue({
|
||||
id: "conv-1",
|
||||
documentScoped: true,
|
||||
messageCount: 2,
|
||||
attachmentIds: ["doc-x"],
|
||||
});
|
||||
const res = await POST(
|
||||
postReq({ documentId: "doc-a" }) as never,
|
||||
postCtx() as never,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(attachDocument).toHaveBeenCalledWith({
|
||||
conversationId: "conv-1",
|
||||
documentId: "doc-a",
|
||||
});
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("audits the tool→fenced flip when attaching to a NON-fenced thread with prior messages", async () => {
|
||||
vi.mocked(fetchConversationAttachmentState).mockResolvedValue({
|
||||
id: "conv-1",
|
||||
documentScoped: false,
|
||||
messageCount: 4,
|
||||
attachmentIds: [],
|
||||
});
|
||||
const res = await POST(
|
||||
postReq({ documentId: "doc-a" }) as never,
|
||||
postCtx() as never,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(attachDocument).toHaveBeenCalled();
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"coach.attachments.converted",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: { conversationId: "conv-1", priorMessages: 4 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE detach", () => {
|
||||
it("404s a missing row / foreign conversation", async () => {
|
||||
vi.mocked(detachDocument).mockResolvedValue(false);
|
||||
const res = await DELETE(
|
||||
new NextRequest(
|
||||
new URL("http://localhost/api/insights/chat/conv-1/attachments/doc-a"),
|
||||
{ method: "DELETE" },
|
||||
) as never,
|
||||
delCtx() as never,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("detaches and reports the conversation still FENCED (flag never cleared)", async () => {
|
||||
vi.mocked(detachDocument).mockResolvedValue(true);
|
||||
const res = await DELETE(
|
||||
new NextRequest(
|
||||
new URL("http://localhost/api/insights/chat/conv-1/attachments/doc-a"),
|
||||
{ method: "DELETE" },
|
||||
) as never,
|
||||
delCtx() as never,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.fenced).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* v1.29.x (S7) — attach a stored document to an EXISTING coach conversation.
|
||||
*
|
||||
* POST validates the document (owned + live + indexed + within cap), creates the
|
||||
* join row (idempotent on the composite PK), and sets the sticky `documentScoped`
|
||||
* flag TRUE. This is the ONE legal, privilege-REDUCING tool→fenced flip: a tool
|
||||
* conversation may become fenced (a fenced turn can DO nothing an injection could
|
||||
* exploit), never the reverse. The flip is audit-logged.
|
||||
*/
|
||||
import { type NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
import {
|
||||
attachDocument,
|
||||
fetchConversationAttachmentState,
|
||||
loadConversationAttachmentDTOs,
|
||||
} from "@/lib/ai/coach/persistence";
|
||||
import {
|
||||
DOCUMENT_CHAT_BUCKET,
|
||||
DOCUMENT_CHAT_LIMIT_PER_MINUTE,
|
||||
DOCUMENT_CHAT_WINDOW_MS,
|
||||
} from "@/lib/documents/ai-route-support";
|
||||
import { validateAttachmentCandidate } from "@/lib/documents/attach-validate";
|
||||
import { coachAttachmentCreateSchema } from "@/lib/validations/inbound-documents";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const userId = user.id;
|
||||
|
||||
const gate = await requireModuleEnabled(userId, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { id: conversationId } = await params;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`${DOCUMENT_CHAT_BUCKET}:${userId}`,
|
||||
DOCUMENT_CHAT_LIMIT_PER_MINUTE,
|
||||
DOCUMENT_CHAT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests. Try again later.", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
});
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = coachAttachmentCreateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid attachment request", 422, {
|
||||
errorCode: "coach.fenced.invalid",
|
||||
});
|
||||
}
|
||||
const { documentId } = parsed.data;
|
||||
|
||||
// Ownership of the conversation — NOT filtered on `documentScoped` (this is
|
||||
// the one legal mode transition). 404 on miss (no cross-user leak).
|
||||
const state = await fetchConversationAttachmentState(
|
||||
userId,
|
||||
conversationId,
|
||||
);
|
||||
if (!state) {
|
||||
return apiError("Conversation not found", 404, {
|
||||
errorCode: "coach.conversation.notFound",
|
||||
});
|
||||
}
|
||||
|
||||
// Idempotent: an already-attached document is a success (UI retries are safe).
|
||||
if (state.attachmentIds.includes(documentId)) {
|
||||
const attachments = await loadConversationAttachmentDTOs(conversationId);
|
||||
return apiSuccess({ attachments, fenced: true });
|
||||
}
|
||||
|
||||
const check = await validateAttachmentCandidate({
|
||||
userId,
|
||||
documentId,
|
||||
currentLiveCount: state.attachmentIds.length,
|
||||
incoming: 1,
|
||||
});
|
||||
if (!check.ok) {
|
||||
return apiError("Attachment rejected", check.status, {
|
||||
errorCode: check.errorCode,
|
||||
});
|
||||
}
|
||||
|
||||
const flip = !state.documentScoped;
|
||||
await attachDocument({ conversationId, documentId });
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.attachments.added" },
|
||||
meta: { conversationId, documentId, converted: flip },
|
||||
});
|
||||
if (flip) {
|
||||
// Mode transition (tool → fenced) is audit-worthy. Privilege-reducing, so
|
||||
// an unconfirmed API call is safe — the client shows the confirm dialog.
|
||||
annotate({
|
||||
action: { name: "coach.attachments.converted" },
|
||||
meta: { conversationId, priorMessages: state.messageCount },
|
||||
});
|
||||
await auditLog("coach.attachments.converted", {
|
||||
userId,
|
||||
details: { conversationId, priorMessages: state.messageCount },
|
||||
});
|
||||
}
|
||||
|
||||
const attachments = await loadConversationAttachmentDTOs(conversationId);
|
||||
return apiSuccess({ attachments, fenced: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,202 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.29.x (S7) — the tool route's DUAL fence guard (design §2.2, adversarial
|
||||
* tests 2 + 5). A fenced conversation id can never reach the tool loop:
|
||||
* - PRIMARY: the fetch narrows `documentScoped: false`, so a fenced thread is
|
||||
* invisible → 404.
|
||||
* - BACKSTOP: even a `documentScoped: false` row carrying an attachment (flag/
|
||||
* join drift) is refused with a `insights.coach.fence_drift` audit alarm.
|
||||
*
|
||||
* Mock scaffold mirrors route-snapshot-once.test.ts so `../route` imports cleanly.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
|
||||
HttpError: class HttpError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.name = "HttpError";
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
}));
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
}));
|
||||
const { annotate } = vi.hoisted(() => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate }));
|
||||
const { auditLog } = vi.hoisted(() => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn(async () => ({ coachPrefsJson: null })) },
|
||||
coachConversation: { findFirst: vi.fn(async () => ({ id: "c1" })) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
AllProvidersFailedError: class extends Error {},
|
||||
runStreamingRawCompletionWithFallback: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderChain: vi.fn(async () => [
|
||||
{ providerType: "admin-openai", instance: { supportsTools: false } },
|
||||
]),
|
||||
resolveProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertConsentForChain: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
|
||||
vi.mock("@/lib/ai/ai-budgets", () => ({
|
||||
AI_BUDGETS: { coach: { maxTokens: 1500, temperature: 0.4 } },
|
||||
}));
|
||||
const { fetchConversationWithMessages } = vi.hoisted(() => ({
|
||||
fetchConversationWithMessages: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/persistence", () => ({
|
||||
appendMessage: vi.fn(async () => ({ id: "m1" })),
|
||||
createConversation: vi.fn(async () => ({ id: "c1" })),
|
||||
fetchConversationWithMessages,
|
||||
listConversations: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({
|
||||
enqueueCoachMemoryRefresh: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/facts", () => ({
|
||||
storeDeterministicFacts: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-16"),
|
||||
reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 1500 })),
|
||||
reconcileSpend: vi.fn(async () => undefined),
|
||||
resolveDailyCap: vi.fn(() => 2_000_000),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/refusal", () => ({
|
||||
detectRefusal: vi.fn(() => ({ refuse: false })),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/outbound-guard", () => ({
|
||||
screenCoachReply: vi.fn(() => ({ block: false })),
|
||||
coachOutboundFallback: vi.fn(() => "fallback"),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/system-prompt", () => ({
|
||||
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/snapshot", () => ({
|
||||
buildCoachSnapshot: vi.fn(async () => ({
|
||||
snapshotJson: "{}",
|
||||
provenance: { windows: ["last30days"], metrics: ["bp"] },
|
||||
referenceGrounding: "",
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/keyvalues", () => ({
|
||||
parseKeyValuesSentinel: vi.fn(() => ({
|
||||
prose: "ok",
|
||||
keyValues: [],
|
||||
malformed: false,
|
||||
malformedEntries: [],
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/suggest-reminder", () => ({
|
||||
parseSuggestReminder: vi.fn(() => ({ prose: "ok" })),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/suggest-gate", () => ({ gateSuggestion: vi.fn() }));
|
||||
vi.mock("@/lib/validations/coach-prefs", () => ({
|
||||
parseCoachPrefs: vi.fn(() => ({ defaultWindow: undefined })),
|
||||
DEFAULT_REMINDER_SUGGESTION_PREFS: {},
|
||||
}));
|
||||
vi.mock("@/lib/sse/create-stream", () => ({
|
||||
createSseStream: () => new ReadableStream(),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
|
||||
const post = POST as unknown as (req: Request) => Promise<Response>;
|
||||
|
||||
function chatReq(body: Record<string, unknown>): Request {
|
||||
return new Request("http://localhost/api/insights/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function statusOfThrow(body: Record<string, unknown>): Promise<number> {
|
||||
try {
|
||||
await post(chatReq(body));
|
||||
return 0;
|
||||
} catch (err) {
|
||||
return (err as { status?: number }).status ?? -1;
|
||||
}
|
||||
}
|
||||
|
||||
describe("POST /api/insights/chat — fence guard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("fetches with documentScoped:false (a fenced thread is invisible here) and 404s", async () => {
|
||||
fetchConversationWithMessages.mockResolvedValue(null);
|
||||
const status = await statusOfThrow({
|
||||
conversationId: "fenced-conv",
|
||||
message: "hi",
|
||||
});
|
||||
expect(status).toBe(404);
|
||||
expect(fetchConversationWithMessages).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
"fenced-conv",
|
||||
{ documentScoped: false },
|
||||
);
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed with a fence_drift audit alarm when a documentScoped:false row still carries an attachment", async () => {
|
||||
fetchConversationWithMessages.mockResolvedValue({
|
||||
id: "drifted",
|
||||
title: "t",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
messageCount: 1,
|
||||
fenced: false,
|
||||
attachments: [{ documentId: "doc-x", title: "x" }],
|
||||
attachmentCount: 1,
|
||||
messages: [],
|
||||
summary: null,
|
||||
});
|
||||
const status = await statusOfThrow({
|
||||
conversationId: "drifted",
|
||||
message: "hi",
|
||||
});
|
||||
expect(status).toBe(404);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"insights.coach.fence_drift",
|
||||
expect.objectContaining({
|
||||
userId: "u1",
|
||||
details: { conversationId: "drifted" },
|
||||
}),
|
||||
);
|
||||
expect(annotate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: { name: "insights.coach.fence_drift" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.29.x (S7) — the fenced multi-document endpoint's ORCHESTRATION (design §3).
|
||||
* The turn pipeline itself is covered in fenced-chat-pipeline.test.ts; here we
|
||||
* pin: `.strict()` body smuggling (test 8), the attachmentIds+conversationId
|
||||
* conflict (test 8), all-or-nothing first-turn validation / IDOR (tests 10, 22,
|
||||
* 24), the fenced-only fetch for an existing thread (test 11), and refusal wiring.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn().mockResolvedValue("en"),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/persistence", () => ({
|
||||
createConversation: vi.fn(),
|
||||
fetchConversationWithMessages: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/documents/attach-validate", () => ({
|
||||
validateAttachmentCandidate: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/documents/fenced-chat", () => ({
|
||||
loadFencedDocuments: vi.fn(),
|
||||
screenFencedInbound: vi.fn(() => ({ refuse: false })),
|
||||
streamFencedReply: vi.fn(async () => new Response("stream", { status: 200 })),
|
||||
streamFencedRefusal: vi.fn(
|
||||
async () => new Response("refusal", { status: 200 }),
|
||||
),
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import {
|
||||
createConversation,
|
||||
fetchConversationWithMessages,
|
||||
} from "@/lib/ai/coach/persistence";
|
||||
import { validateAttachmentCandidate } from "@/lib/documents/attach-validate";
|
||||
import {
|
||||
loadFencedDocuments,
|
||||
screenFencedInbound,
|
||||
streamFencedReply,
|
||||
streamFencedRefusal,
|
||||
} from "@/lib/documents/fenced-chat";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "t", role: "USER" as const, locale: "en" },
|
||||
};
|
||||
|
||||
const req = (body: unknown) =>
|
||||
new NextRequest(new URL("http://localhost/api/insights/chat/fenced"), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
async function errorCode(res: Response): Promise<string | undefined> {
|
||||
const body = await res.json();
|
||||
return body.meta?.errorCode ?? body.errorCode;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(screenFencedInbound).mockReturnValue({ refuse: false });
|
||||
vi.mocked(validateAttachmentCandidate).mockResolvedValue({ ok: true });
|
||||
vi.mocked(loadFencedDocuments).mockResolvedValue({
|
||||
ok: true,
|
||||
docs: [
|
||||
{
|
||||
documentId: "doc-a",
|
||||
title: "A",
|
||||
filename: null,
|
||||
text: "t",
|
||||
source: "verbatim",
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(createConversation).mockResolvedValue({
|
||||
id: "conv-new",
|
||||
title: "t",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
messageCount: 0,
|
||||
fenced: true,
|
||||
attachments: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("fenced route — body smuggling (.strict)", () => {
|
||||
it.each(["scope", "guidedQuestion", "prefill", "userId"])(
|
||||
"422s a body carrying the tool-mode / userId field %s",
|
||||
async (field) => {
|
||||
const res = await POST(
|
||||
req({ message: "hi", attachmentIds: ["doc-a"], [field]: "x" }) as never,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(streamFencedReply).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("422s attachmentIds together with conversationId (one write path per concern)", async () => {
|
||||
const res = await POST(
|
||||
req({
|
||||
conversationId: "c1",
|
||||
message: "hi",
|
||||
attachmentIds: ["doc-a"],
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(await errorCode(res)).toBe("coach.fenced.attachmentConflict");
|
||||
expect(createConversation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fenced route — first-turn create (all-or-nothing validation)", () => {
|
||||
it("creates a fenced conversation when every attachment validates", async () => {
|
||||
const res = await POST(
|
||||
req({ message: "summarise", attachmentIds: ["doc-a", "doc-b"] }) as never,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(createConversation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
documentScoped: true,
|
||||
attachmentIds: ["doc-a", "doc-b"],
|
||||
}),
|
||||
);
|
||||
expect(streamFencedReply).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails the WHOLE request and creates NOTHING when one attachment is foreign (IDOR)", async () => {
|
||||
vi.mocked(validateAttachmentCandidate)
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
errorCode: "documents.inbound.notFound",
|
||||
});
|
||||
const res = await POST(
|
||||
req({ message: "x", attachmentIds: ["mine", "someone-elses"] }) as never,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(createConversation).not.toHaveBeenCalled();
|
||||
expect(streamFencedReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s (attachmentLimit) when a candidate exceeds the cap", async () => {
|
||||
vi.mocked(validateAttachmentCandidate).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 422,
|
||||
errorCode: "coach.fenced.attachmentLimit",
|
||||
});
|
||||
const res = await POST(
|
||||
req({ message: "x", attachmentIds: ["doc-a"] }) as never,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(await errorCode(res)).toBe("coach.fenced.attachmentLimit");
|
||||
});
|
||||
|
||||
it("422s (attachmentRequired) when a fresh conversation has no attachments", async () => {
|
||||
const res = await POST(req({ message: "x" }) as never);
|
||||
expect(res.status).toBe(422);
|
||||
expect(await errorCode(res)).toBe("coach.fenced.attachmentRequired");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fenced route — existing conversation", () => {
|
||||
it("fetches with documentScoped:true and 404s a non-fenced / foreign id", async () => {
|
||||
vi.mocked(fetchConversationWithMessages).mockResolvedValue(null);
|
||||
const res = await POST(
|
||||
req({ conversationId: "tool-or-foreign", message: "hi" }) as never,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(fetchConversationWithMessages).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"tool-or-foreign",
|
||||
{ documentScoped: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("grounds a continued turn on ALL of the thread's live attachments", async () => {
|
||||
vi.mocked(fetchConversationWithMessages).mockResolvedValue({
|
||||
id: "conv-1",
|
||||
fenced: true,
|
||||
attachments: [
|
||||
{ documentId: "doc-a", title: "A" },
|
||||
{ documentId: "doc-b", title: "B" },
|
||||
],
|
||||
attachmentCount: 2,
|
||||
messages: [{ role: "user", content: "earlier", id: "m", createdAt: "" }],
|
||||
} as never);
|
||||
await POST(req({ conversationId: "conv-1", message: "next" }) as never);
|
||||
expect(loadFencedDocuments).toHaveBeenCalledWith("user-1", [
|
||||
"doc-a",
|
||||
"doc-b",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fenced route — send-time attachment unavailable", () => {
|
||||
it("422s naming the doc when an attachment cannot be loaded (never partial context)", async () => {
|
||||
vi.mocked(fetchConversationWithMessages).mockResolvedValue({
|
||||
id: "conv-1",
|
||||
fenced: true,
|
||||
attachments: [{ documentId: "doc-dead", title: null }],
|
||||
attachmentCount: 1,
|
||||
messages: [],
|
||||
} as never);
|
||||
vi.mocked(loadFencedDocuments).mockResolvedValue({
|
||||
ok: false,
|
||||
unavailableDocId: "doc-dead",
|
||||
} as never);
|
||||
const res = await POST(
|
||||
req({ conversationId: "conv-1", message: "hi" }) as never,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(await errorCode(res)).toBe("coach.fenced.attachmentUnavailable");
|
||||
expect(streamFencedReply).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fenced route — refusal", () => {
|
||||
it("streams a refusal (no provider) when the inbound message is injection-shaped", async () => {
|
||||
vi.mocked(screenFencedInbound).mockReturnValue({
|
||||
refuse: true,
|
||||
reason: "prompt_injection",
|
||||
refusalText: "no",
|
||||
replayTurnIndex: null,
|
||||
});
|
||||
const res = await POST(
|
||||
req({ message: "x", attachmentIds: ["doc-a"] }) as never,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(streamFencedRefusal).toHaveBeenCalled();
|
||||
expect(streamFencedReply).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* v1.29.x (S7) — the FENCED multi-document coach chat endpoint.
|
||||
*
|
||||
* POST streams a grounded prose reply about the N documents attached to a coach
|
||||
* conversation, as Server-Sent Events. Conversation-keyed (the conversation owns
|
||||
* a SET of documents), sharing the whole turn pipeline with the single-doc sheet
|
||||
* route via `@/lib/documents/fenced-chat` so the two cannot drift.
|
||||
*
|
||||
* THE FENCE (see `fenced-chat.ts` for the full posture): NO tools, NO health
|
||||
* snapshot; every document fenced as untrusted DATA; per-document egress consent;
|
||||
* numeric grounding over the LIVE attachment union only. A plain tool
|
||||
* conversation 404s here (`documentScoped: true` in the fetch), so a fenced turn
|
||||
* can never be appended into a tool thread's history.
|
||||
*/
|
||||
import { type NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiError, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
import {
|
||||
createConversation,
|
||||
fetchConversationWithMessages,
|
||||
} from "@/lib/ai/coach/persistence";
|
||||
import {
|
||||
DOCUMENT_CHAT_BUCKET,
|
||||
DOCUMENT_CHAT_LIMIT_PER_MINUTE,
|
||||
DOCUMENT_CHAT_WINDOW_MS,
|
||||
} from "@/lib/documents/ai-route-support";
|
||||
import { validateAttachmentCandidate } from "@/lib/documents/attach-validate";
|
||||
import {
|
||||
loadFencedDocuments,
|
||||
screenFencedInbound,
|
||||
streamFencedReply,
|
||||
streamFencedRefusal,
|
||||
type FencedTurn,
|
||||
} from "@/lib/documents/fenced-chat";
|
||||
import { fencedChatRequestSchema } from "@/lib/validations/inbound-documents";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const userId = user.id;
|
||||
|
||||
const gate = await requireModuleEnabled(userId, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = fencedChatRequestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid fenced chat request", 422, {
|
||||
errorCode: "coach.fenced.invalid",
|
||||
});
|
||||
}
|
||||
const {
|
||||
conversationId,
|
||||
message,
|
||||
locale: bodyLocale,
|
||||
attachmentIds,
|
||||
} = parsed.data;
|
||||
|
||||
// One write path per concern: attach-to-existing goes through the attach
|
||||
// endpoint, not a first-turn `attachmentIds` payload with a conversationId.
|
||||
if (conversationId && attachmentIds) {
|
||||
return apiError("attachmentIds may not accompany conversationId", 422, {
|
||||
errorCode: "coach.fenced.attachmentConflict",
|
||||
});
|
||||
}
|
||||
|
||||
// Per-user request-rate ceiling — shared with the single-doc chat + the
|
||||
// attach/detach mutations, so a user cannot double their budget by alternating.
|
||||
const rl = await checkRateLimit(
|
||||
`${DOCUMENT_CHAT_BUCKET}:${userId}`,
|
||||
DOCUMENT_CHAT_LIMIT_PER_MINUTE,
|
||||
DOCUMENT_CHAT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests. Try again later.", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
});
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
override: bodyLocale,
|
||||
userLocale: user.locale ?? null,
|
||||
});
|
||||
const contractLocale = locale === "de" ? "de" : "en";
|
||||
|
||||
// ── Resolve the conversation (fenced-only) ──────────────────────────────
|
||||
let workingConversationId: string;
|
||||
let documentIds: string[];
|
||||
let priorTurns: FencedTurn[] = [];
|
||||
|
||||
if (conversationId) {
|
||||
const existing = await fetchConversationWithMessages(
|
||||
userId,
|
||||
conversationId,
|
||||
{
|
||||
documentScoped: true,
|
||||
},
|
||||
);
|
||||
if (!existing) {
|
||||
// 404, not 403 — never reveal cross-user / cross-mode existence.
|
||||
throw new HttpError(404, "coach.conversation.notFound");
|
||||
}
|
||||
workingConversationId = existing.id;
|
||||
documentIds = (existing.attachments ?? []).map((a) => a.documentId);
|
||||
priorTurns = existing.messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
} else {
|
||||
if (!attachmentIds || attachmentIds.length === 0) {
|
||||
return apiError(
|
||||
"A new fenced conversation needs at least one document",
|
||||
422,
|
||||
{
|
||||
errorCode: "coach.fenced.attachmentRequired",
|
||||
},
|
||||
);
|
||||
}
|
||||
// Validate EVERY id before creating anything — a failure creates nothing.
|
||||
for (const documentId of attachmentIds) {
|
||||
const check = await validateAttachmentCandidate({
|
||||
userId,
|
||||
documentId,
|
||||
currentLiveCount: 0,
|
||||
incoming: attachmentIds.length,
|
||||
});
|
||||
if (!check.ok) {
|
||||
return apiError("Attachment rejected", check.status, {
|
||||
errorCode: check.errorCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
const created = await createConversation({
|
||||
userId,
|
||||
title: message,
|
||||
documentScoped: true,
|
||||
attachmentIds,
|
||||
});
|
||||
workingConversationId = created.id;
|
||||
documentIds = attachmentIds;
|
||||
annotate({
|
||||
action: { name: "coach.attachments.added" },
|
||||
meta: {
|
||||
conversationId: workingConversationId,
|
||||
attachmentCount: attachmentIds.length,
|
||||
firstTurn: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Inbound refusal / replay-injection guard ────────────────────────────
|
||||
const screen = screenFencedInbound({ message, priorTurns, locale });
|
||||
if (screen.refuse) {
|
||||
if (screen.replayTurnIndex !== null) {
|
||||
annotate({
|
||||
action: { name: "documents.chat.replay_injection" },
|
||||
meta: { reason: screen.reason, turnIndex: screen.replayTurnIndex },
|
||||
});
|
||||
await auditLog("documents.chat.replay_injection", {
|
||||
userId,
|
||||
details: {
|
||||
conversationId: workingConversationId,
|
||||
turnIndex: screen.replayTurnIndex,
|
||||
reason: screen.reason,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
annotate({
|
||||
action: { name: "documents.chat.refused" },
|
||||
meta: { reason: screen.reason },
|
||||
});
|
||||
}
|
||||
return streamFencedRefusal({
|
||||
conversationId: workingConversationId,
|
||||
message,
|
||||
refusalText: screen.refusalText,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Load the LIVE attachment contexts (owner-scoped, indexed-only) ──────
|
||||
const loaded = await loadFencedDocuments(userId, documentIds);
|
||||
if (!loaded.ok) {
|
||||
// Never a partial-context reply: naming the unreadable document lets the UI
|
||||
// point the user at the dead pill.
|
||||
return apiError("An attached document is unavailable", 422, {
|
||||
errorCode: "coach.fenced.attachmentUnavailable",
|
||||
unavailableDocumentId: loaded.unavailableDocId,
|
||||
});
|
||||
}
|
||||
|
||||
return streamFencedReply({
|
||||
userId,
|
||||
conversationId: workingConversationId,
|
||||
docs: loaded.docs,
|
||||
priorTurns,
|
||||
message,
|
||||
contractLocale,
|
||||
locale,
|
||||
signal: request.signal,
|
||||
});
|
||||
});
|
||||
@@ -304,22 +304,39 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
let priorSummary: string | null = null;
|
||||
|
||||
if (conversationId) {
|
||||
// v1.27.33 / v1.28.51 — the SEND path stays scoped to Coach threads
|
||||
// (`documentId: null`) DELIBERATELY, even though the read paths (rail list +
|
||||
// detail GET) were relaxed to surface doc-scoped threads in the Coach UI.
|
||||
// This is the load-bearing prompt-injection fence: a document chat runs its
|
||||
// tool-loop + health-snapshot NEVER — a doc turn is routed by the client to
|
||||
// the hardened `/api/documents/inbound/[id]/chat` endpoint. Should a
|
||||
// doc-scoped conversation id ever reach THIS tool route, the null-scope
|
||||
// fetch below returns nothing and the turn 404s rather than executing an
|
||||
// injected instruction against the coach's write tools. Do not relax.
|
||||
// v1.29.x (S7) — the load-bearing prompt-injection fence, now a DUAL
|
||||
// predicate. Untrusted document text may only enter an LLM prompt on the
|
||||
// fenced pipeline (no tools, no snapshot). This tool route must never load a
|
||||
// fenced conversation:
|
||||
// PRIMARY — `documentScoped: false` in the WHERE. The sticky flag is set
|
||||
// at fenced-creation / first-attach and NEVER cleared, so a
|
||||
// conversation that has EVER held a document 404s here forever.
|
||||
// BACKSTOP — even so, assert zero live attachments. `documentScoped: false`
|
||||
// with an attachment row present is flag/join drift (an
|
||||
// invariant broke somewhere): fail closed, loudly.
|
||||
// A doc turn is routed by the client to `/api/insights/chat/fenced` (or the
|
||||
// single-doc sheet endpoint). Should a fenced id ever reach THIS route the
|
||||
// fetch returns nothing and the turn 404s rather than running an injected
|
||||
// instruction against the coach's write tools. Do not relax.
|
||||
const existing = await fetchConversationWithMessages(
|
||||
userId,
|
||||
conversationId,
|
||||
{ documentId: null },
|
||||
{ documentScoped: false },
|
||||
);
|
||||
if (!existing) {
|
||||
// 404, not 403 — never reveal cross-user existence
|
||||
// 404, not 403 — never reveal cross-user / cross-mode existence
|
||||
throw new HttpError(404, "coach.conversation.notFound");
|
||||
}
|
||||
if (existing.attachmentCount > 0) {
|
||||
// Drift alarm — telemetry AND an audit row (not routine telemetry).
|
||||
annotate({
|
||||
action: { name: "insights.coach.fence_drift" },
|
||||
meta: { conversationId: existing.id },
|
||||
});
|
||||
await auditLog("insights.coach.fence_drift", {
|
||||
userId,
|
||||
details: { conversationId: existing.id },
|
||||
});
|
||||
throw new HttpError(404, "coach.conversation.notFound");
|
||||
}
|
||||
workingConversationId = existing.id;
|
||||
|
||||
Reference in new issue
Block a user