diff --git a/CHANGELOG.md b/CHANGELOG.md index f7afa492a..0c5aa44f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,28 @@ ## [Unreleased] -## [1.28.55] — 2026-07-16 — The Today view comes to life +## [1.28.56] — 2026-07-17 — Documents, closer to hand + +Four changes bring documents and your record closer together: + +- **Ask the Coach opens beside your document.** Asking about a document now + opens the Coach in a side panel rather than jumping to a full page — expand it + to full screen whenever you like, and it keeps its place. +- **Bring documents into a Coach chat.** From the composer's "+", attach one or + more documents — pick from your vault or upload a new one, at the start or + mid-conversation — and the Coach answers grounded in them. A conversation with + a document runs in its fenced mode: grounded in those documents only, with no + access to your health data or tools, so nothing in an uploaded file can reach + anything it shouldn't. +- **Lab values from a document, proposed for your labs.** When a document you've + had read looks like lab work, its values are detected and proposed — one tap to + add them to your labs; they're never added without your say-so. +- **A lab scan is also kept.** Scanning a lab report into your labs now also + files the document in your vault (encrypted, like every upload) and links the + two, so the reading and its source stay together. + +Migration 0245 (attaching multiple documents to a conversation) accompanies this +release; existing document chats carry over unchanged. Three pieces turn the Today view into a daily loop: diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index baab5f7d1..cdf406dea 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.28.55 + version: 1.28.56 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -5281,6 +5281,124 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/insights/chat/fenced: + post: + tags: + - Insights + summary: Send a FENCED multi-document coach turn (streaming reply) + description: "v1.29.x (S7) — sends a user turn and streams a grounded prose reply about the documents attached to a + coach conversation, as Server-Sent Events (`text/event-stream`: one `data: \\n\\n` frame per event; + `token` / `done` / `error`, HTTP 200 even for a provider/refusal outcome). FENCED by construction: NO tools, NO + health snapshot; every attached document is fenced as untrusted DATA (per-document header fields + marker-scrubbed); the single picked provider is egress-consent-checked once per attached document (403 + `consent.ai.required` if any fails); the reply is numerically grounded against the LIVE attachments' figures + only. `conversationId` continues an existing fenced thread; `attachmentIds` (first-turn ONLY, min 1, max 5) + creates a fresh fenced thread — supplying BOTH is a 422. The body is `.strict()`: `scope` / `guidedQuestion` / + `prefill` / `userId` are rejected. A plain tool conversation 404s here. Module-gated on `inboundDocuments`; + rate-limited (shared `document-chat` bucket). Renders as plain text (no markdown). Auth via cookie or Bearer." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/FencedCoachChatRequest" + responses: + "200": + description: Server-Sent Events stream of `token` / `done` / `error` frames. + content: + text/event-stream: + schema: + type: string + description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." + "401": *a1 + "403": + description: "AI consent required for an external provider (`errorCode: consent.ai.required`). A 422 (`stdResponses`) + covers an un-indexed / unavailable attachment (`coach.fenced.attachmentUnavailable`), the attachment cap + (`coach.fenced.attachmentLimit`), or `attachmentIds` sent with a `conversationId` + (`coach.fenced.attachmentConflict`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/insights/chat/{id}/attachments: + post: + tags: + - Insights + summary: Attach a document to a coach conversation + description: "v1.29.x (S7) — attaches one already-stored, content-indexed document to an existing conversation and sets + its sticky `documentScoped` flag TRUE (the one legal, privilege-reducing tool→fenced transition; audit-logged + when a flip occurs). Validates the document is owned + live + indexed + within the 5-document cap. Idempotent: + attaching an already-attached document is a 200. A foreign / unknown conversation or document maps to 404. + Module-gated on `inboundDocuments`; rate-limited. Auth via cookie or Bearer." + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Conversation id. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CoachAttachmentCreateRequest" + responses: + "200": + description: The refreshed live attachment set + the fenced flag. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachAttachmentsResponse" + "401": *a1 + "404": + description: Conversation or document not found / not owned. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/insights/chat/{id}/attachments/{documentId}: + delete: + tags: + - Insights + summary: Detach a document from a coach conversation + description: v1.29.x (S7) — removes the document from the conversation. The conversation stays FENCED (the sticky flag + is never cleared — a thread whose history may contain document-derived text must never regain the tool loop). + Detaching the last document leaves a fenced conversation with zero attachments. A missing row / foreign + conversation maps to 404. Module-gated on `inboundDocuments`; rate-limited. Auth via cookie or Bearer. + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Conversation id. + - name: documentId + in: path + required: true + schema: + type: string + description: The attached document's id. + responses: + "200": + description: The refreshed live attachment set + the fenced flag. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachAttachmentsResponseDetach" + "401": *a1 + "404": + description: Attachment or conversation not found / not owned. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/insights/layout: get: tags: @@ -7911,6 +8029,10 @@ paths: required: - analyte - takenAt + documentId: + type: string + minLength: 1 + maxLength: 64 required: - rows responses: @@ -12110,6 +12232,43 @@ components: supplied keys over the persisted row, so a PATCH touching only `medication` leaves the siblings intact. `medication.lowStockRunwayDays` (1–60, nullable) is the low-stock alert threshold in remaining runway days — `null` switches the alert off. + FencedCoachChatRequest: + type: object + properties: + conversationId: + type: string + minLength: 1 + maxLength: 64 + message: + type: string + minLength: 1 + maxLength: 4000 + locale: + type: string + enum: + - en + - de + attachmentIds: + minItems: 1 + maxItems: 5 + type: array + items: + type: string + minLength: 1 + maxLength: 64 + required: + - message + additionalProperties: false + CoachAttachmentCreateRequest: + type: object + properties: + documentId: + type: string + minLength: 1 + maxLength: 64 + required: + - documentId + additionalProperties: false InsightsLayoutBody: type: object properties: @@ -21954,15 +22113,20 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 - documentId: - description: When set, this thread is scoped to one stored document (a document chat, run on the hardened fenced - endpoint). Null/absent for a normal health thread. - anyOf: - - type: string - - type: "null" + fenced: + type: boolean + description: "v1.29.x (S7) — the sticky fence flag. true = a FENCED thread: its turns route through the hardened fenced + endpoint (no tools, no health snapshot), never the tool route. Permanent once true. false = a normal health + thread." + attachments: + description: v1.29.x (S7) — the LIVE set of documents attached to this thread. Empty on a health thread (or a fenced + thread whose attachments were all detached). + type: array + items: + $ref: "#/components/schemas/CoachConversationAttachment" documentTitle: - description: The owning document's resolved title (title, falling back to filename) for the scope badge. Null on a - health thread or when the document has neither. + description: v1.29.x (S7) — the FIRST attachment's resolved title, kept for the rail's single-line badge. Null on a + health thread or a fenced thread with no live attachment. anyOf: - type: string - type: "null" @@ -21972,8 +22136,24 @@ components: - createdAt - updatedAt - messageCount + - fenced additionalProperties: false description: Lightweight conversation metadata for the history rail. No message bodies — the rail does not decrypt. + CoachConversationAttachment: + type: object + properties: + documentId: + type: string + title: + anyOf: + - type: string + - type: "null" + description: The attached document's resolved title (title, falling back to filename). Null when it has neither. + Plaintext; no health values. + required: + - documentId + - title + additionalProperties: false CoachConversationDetailResponse: type: object properties: @@ -22020,14 +22200,22 @@ components: anyOf: - type: string - type: "null" - documentId: - description: When set, this thread is scoped to one stored document; the client routes its turns through the hardened - fenced document endpoint, never the tool route. - anyOf: - - type: string - - type: "null" + fenced: + type: boolean + description: v1.29.x (S7) — the sticky fence flag; the client routes a fenced thread's turns through the hardened fenced + endpoint, never the tool route. + attachments: + description: v1.29.x (S7) — the LIVE set of attached documents (join rows), attach-time order. + type: array + items: + $ref: "#/components/schemas/CoachConversationAttachment" + attachmentCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: v1.29.x (S7) — count of live attachment rows (equal to attachments.length). documentTitle: - description: The owning document's resolved title for the scope badge. Null on a health thread. + description: v1.29.x (S7) — the first attachment's resolved title for the rail badge. Null on a health thread. anyOf: - type: string - type: "null" @@ -22038,6 +22226,8 @@ components: - updatedAt - messageCount - messages + - fenced + - attachmentCount additionalProperties: false description: Full conversation with every message decrypted server-side. The client renders the bodies directly; no decryption key is involved. @@ -22193,6 +22383,55 @@ components: - data - error additionalProperties: false + CoachAttachmentsResponse: + type: object + properties: + data: + $ref: "#/components/schemas/CoachAttachments" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachAttachments: + type: object + properties: + attachments: + type: array + items: + $ref: "#/components/schemas/CoachConversationAttachment" + description: The conversation's refreshed live attachment set. + fenced: + type: boolean + description: The sticky fence flag — always true after an attach; unchanged (stays true) by a detach. + required: + - attachments + - fenced + additionalProperties: false + CoachAttachmentsResponseDetach: + type: object + properties: + data: + $ref: "#/components/schemas/CoachAttachments" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false InsightsLayout: type: object properties: diff --git a/e2e/documents-chat.spec.ts b/e2e/documents-chat.spec.ts index 4d2cd7c14..e57fc0b78 100644 --- a/e2e/documents-chat.spec.ts +++ b/e2e/documents-chat.spec.ts @@ -1,19 +1,21 @@ /** - * Document vault — the "chat about a document" entry point (v1.28.51, R3). + * Document vault — the "chat about a document" entry point (v1.28.52, R3). * - * The bespoke in-sheet chat drawer is gone (Documents R3, Design A). Asking the - * Coach about a document now hands off to the REAL Coach, scoped to that - * document: the detail sheet's Coach affordance navigates to `/coach?doc=`, - * where the conversation runs on the same hardened fenced endpoint but inside - * the Coach's own surface and history. + * The bespoke in-sheet chat drawer is gone (Documents R3). Asking the Coach + * about a document now opens the REAL Coach conversation, scoped to that + * document, in the shared SIDE DRAWER: the detail sheet's Coach affordance + * closes the sheet and opens the coach drawer pre-scoped to the document (its + * maximize control expands to the full `/coach` page). The conversation runs on + * the same hardened fenced endpoint — only WHERE it renders changed. * * This suite pins the documents-side contract: the affordance is offered only - * when a provider is available, and it navigates to the doc-scoped Coach; with - * NO provider the AI area collapses to the settings pointer and no Coach entry - * renders at all. The Coach-side rendering (the "Chatting about" scope banner + - * the not-indexed hint) is covered by the coach-conversation component test; the - * fenced-send guarantee (a document turn never reaches the tool route) by the - * coach-send-target + persistence-scope unit tests. + * when a provider is available, and it OPENS THE DOC-SCOPED DRAWER in place (no + * full-page nav); with NO provider the AI area collapses to the settings + * pointer and no Coach entry renders at all. The Coach-side rendering (the + * "Chatting about" scope banner + the not-indexed hint) is covered by the + * coach-conversation component test; the fenced-send guarantee (a document turn + * never reaches the tool route) by the coach-send-target + persistence-scope + * unit tests. * * v1.27.31 lesson kept: the sheet's AI area depends on the capability probe + * usage endpoints, so both are mocked — otherwise the AI area never renders and @@ -94,9 +96,9 @@ test.describe("document vault — chat about a document", () => { test.slow(); }); - // ── (a) Provider available: the Coach entry hands off to the doc-scoped Coach ── + // ── (a) Provider available: the Coach entry opens the doc-scoped drawer ── - test("offers the Coach entry and hands off to the document-scoped Coach", async ({ + test("offers the Coach entry and opens the document-scoped Coach drawer in place", async ({ page, }) => { await mockAiEnabled(page); @@ -106,14 +108,20 @@ test.describe("document vault — chat about a document", () => { await expect(sheet).toBeVisible(); // The neutral Coach affordance lives in the detail-sheet AI area; tapping it - // navigates to the real Coach seeded with this document. + // closes the sheet and opens the REAL coach drawer scoped to this document. const open = sheet.locator('[data-slot="document-chat-open"]'); await expect(open).toBeVisible(); await open.click(); - // The documents-side contract: the URL hands off to the doc-scoped Coach. - await page.waitForURL(new RegExp(`/coach\\?doc=${CONTENT_DOC_ID}`)); - expect(new URL(page.url()).searchParams.get("doc")).toBe(CONTENT_DOC_ID); + // The documents-side contract: the coach drawer opens IN PLACE (no + // full-page nav) scoped to this document — its doc-scope banner renders. + const drawer = page.locator('[data-slot="coach-drawer"]'); + await expect(drawer).toBeVisible({ timeout: 10_000 }); + await expect(drawer.locator('[data-slot="coach-doc-scope"]')).toBeVisible({ + timeout: 10_000, + }); + // Still on /documents — the drawer expands to the page only via maximize. + expect(new URL(page.url()).pathname).not.toContain("/coach"); }); // ── (b) No provider: the calm settings pointer, no Coach entry at all ── diff --git a/messages/de.json b/messages/de.json index b0ab994a0..c8907407d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2450,7 +2450,27 @@ "correlationWeightWeekday": "Folgt mein Gewicht einem Wochenmuster? Was sagt mir dieser Wochentags-Zusammenhang?", "recommendation": "Erzähl mir mehr zu dieser Empfehlung: \"{text}\" — warum trifft sie auf mich zu, und was sollte ich tun?" }, - "assessmentPrompt": "Erkläre mir meine {metric}-Einschätzung — was bedeutet sie und was sollte ich tun?" + "assessmentPrompt": "Erkläre mir meine {metric}-Einschätzung — was bedeutet sie und was sollte ich tun?", + "attach": { + "menuChooseFromDocuments": "Aus Dokumenten wählen", + "menuUploadNew": "Neues Dokument hochladen", + "trigger": "Dokumente anhängen", + "pickerTitle": "Dokumente anhängen", + "pickerDescription": "Der Coach liest nur die angehängten Dokumente – nicht deine Gesundheitsdaten.", + "pickerSearchPlaceholder": "Dokumente durchsuchen…", + "pickerEmpty": "Noch keine Dokumente in deinem Tresor.", + "pickerNotIndexed": "Noch nicht indexiert", + "pickerCap": "Du kannst noch bis zu {max} anhängen.", + "pickerConfirm": "Anhängen ({count})", + "pillRemove": "{title} entfernen", + "indexing": "Wird indexiert…", + "indexingHint": "Ein Dokument wird noch indexiert – du kannst senden, sobald es bereit ist.", + "fencedHint": "Diese Unterhaltung läuft ohne Zugriff auf deine Gesundheitsdaten – der Coach liest nur die angehängten Dokumente.", + "documentsAttachedOne": "1 Dokument angehängt", + "documentsAttachedOther": "{count} Dokumente angehängt", + "railBadge": "Dokument-Chat (abgeschottet)", + "uploadError": "Upload fehlgeschlagen. Bitte versuche es erneut." + } }, "relativeJustNow": "gerade eben", "relativeMinutesAgoOne": "vor {count} Minute", diff --git a/messages/en.json b/messages/en.json index 081f05161..11f67878f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2450,7 +2450,27 @@ "correlationWeightWeekday": "Does my weight follow a weekly pattern? What does this day-of-week correlation tell me?", "recommendation": "Tell me more about this recommendation: \"{text}\" — why does it apply to me, and what should I do about it?" }, - "assessmentPrompt": "Walk me through my {metric} assessment — what does it mean and what should I do about it?" + "assessmentPrompt": "Walk me through my {metric} assessment — what does it mean and what should I do about it?", + "attach": { + "menuChooseFromDocuments": "Choose from documents", + "menuUploadNew": "Upload new document", + "trigger": "Attach documents", + "pickerTitle": "Attach documents", + "pickerDescription": "The coach reads only the documents you attach — not your health data.", + "pickerSearchPlaceholder": "Search documents…", + "pickerEmpty": "No documents in your vault yet.", + "pickerNotIndexed": "Not yet indexed", + "pickerCap": "You can attach up to {max} more.", + "pickerConfirm": "Attach ({count})", + "pillRemove": "Remove {title}", + "indexing": "Indexing…", + "indexingHint": "A document is still being indexed — you can send once it's ready.", + "fencedHint": "This conversation runs without access to your health data — the coach only reads the attached documents.", + "documentsAttachedOne": "1 document attached", + "documentsAttachedOther": "{count} documents attached", + "railBadge": "Fenced document chat", + "uploadError": "Upload failed. Please try again." + } }, "relativeJustNow": "just now", "relativeMinutesAgoOne": "{count} minute ago", diff --git a/messages/es.json b/messages/es.json index 11e111c5f..79883e68e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2450,7 +2450,27 @@ "correlationWeightWeekday": "¿Mi peso sigue un patrón semanal? ¿Qué me dice esta correlación por día de la semana?", "recommendation": "Cuéntame más sobre esta recomendación: \"{text}\" — ¿por qué se aplica a mí y qué debería hacer al respecto?" }, - "assessmentPrompt": "Explícame mi evaluación de {metric}: ¿qué significa y qué debería hacer al respecto?" + "assessmentPrompt": "Explícame mi evaluación de {metric}: ¿qué significa y qué debería hacer al respecto?", + "attach": { + "menuChooseFromDocuments": "Elegir de documentos", + "menuUploadNew": "Subir documento nuevo", + "trigger": "Adjuntar documentos", + "pickerTitle": "Adjuntar documentos", + "pickerDescription": "El coach solo lee los documentos que adjuntes, no tus datos de salud.", + "pickerSearchPlaceholder": "Buscar documentos…", + "pickerEmpty": "Aún no tienes documentos en tu archivo.", + "pickerNotIndexed": "Aún no indexado", + "pickerCap": "Puedes adjuntar hasta {max} más.", + "pickerConfirm": "Adjuntar ({count})", + "pillRemove": "Quitar {title}", + "indexing": "Indexando…", + "indexingHint": "Un documento aún se está indexando; podrás enviar cuando esté listo.", + "fencedHint": "Esta conversación se ejecuta sin acceso a tus datos de salud: el coach solo lee los documentos adjuntos.", + "documentsAttachedOne": "1 documento adjunto", + "documentsAttachedOther": "{count} documentos adjuntos", + "railBadge": "Chat de documento aislado", + "uploadError": "Error al subir. Inténtalo de nuevo." + } }, "relativeJustNow": "ahora mismo", "relativeMinutesAgoOne": "hace {count} minuto", diff --git a/messages/fr.json b/messages/fr.json index 8f79bad06..85d4f3acf 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2450,7 +2450,27 @@ "correlationWeightWeekday": "Mon poids suit-il un schéma hebdomadaire ? Que m'indique cette corrélation selon le jour de la semaine ?", "recommendation": "Parle-moi davantage de cette recommandation : \"{text}\" — pourquoi s'applique-t-elle à moi, et que devrais-je faire ?" }, - "assessmentPrompt": "Explique-moi mon évaluation de {metric} : qu'est-ce que cela signifie et que dois-je faire ?" + "assessmentPrompt": "Explique-moi mon évaluation de {metric} : qu'est-ce que cela signifie et que dois-je faire ?", + "attach": { + "menuChooseFromDocuments": "Choisir parmi les documents", + "menuUploadNew": "Téléverser un nouveau document", + "trigger": "Joindre des documents", + "pickerTitle": "Joindre des documents", + "pickerDescription": "Le coach lit uniquement les documents que vous joignez, pas vos données de santé.", + "pickerSearchPlaceholder": "Rechercher des documents…", + "pickerEmpty": "Aucun document dans votre coffre pour l'instant.", + "pickerNotIndexed": "Pas encore indexé", + "pickerCap": "Vous pouvez encore en joindre jusqu'à {max}.", + "pickerConfirm": "Joindre ({count})", + "pillRemove": "Retirer {title}", + "indexing": "Indexation…", + "indexingHint": "Un document est encore en cours d'indexation ; vous pourrez envoyer une fois prêt.", + "fencedHint": "Cette conversation s'exécute sans accès à vos données de santé : le coach lit uniquement les documents joints.", + "documentsAttachedOne": "1 document joint", + "documentsAttachedOther": "{count} documents joints", + "railBadge": "Chat de document cloisonné", + "uploadError": "Échec du téléversement. Veuillez réessayer." + } }, "relativeJustNow": "à l’instant", "relativeMinutesAgoOne": "il y a {count} minute", diff --git a/messages/it.json b/messages/it.json index a80ce17f7..b0702e182 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2450,7 +2450,27 @@ "correlationWeightWeekday": "Il mio peso segue uno schema settimanale? Cosa mi dice questa correlazione per giorno della settimana?", "recommendation": "Dimmi di più su questa raccomandazione: \"{text}\" — perché si applica a me e cosa dovrei fare?" }, - "assessmentPrompt": "Spiegami la mia valutazione di {metric}: cosa significa e cosa dovrei fare?" + "assessmentPrompt": "Spiegami la mia valutazione di {metric}: cosa significa e cosa dovrei fare?", + "attach": { + "menuChooseFromDocuments": "Scegli dai documenti", + "menuUploadNew": "Carica nuovo documento", + "trigger": "Allega documenti", + "pickerTitle": "Allega documenti", + "pickerDescription": "Il coach legge solo i documenti che alleghi, non i tuoi dati sanitari.", + "pickerSearchPlaceholder": "Cerca documenti…", + "pickerEmpty": "Ancora nessun documento nel tuo archivio.", + "pickerNotIndexed": "Non ancora indicizzato", + "pickerCap": "Puoi allegarne ancora fino a {max}.", + "pickerConfirm": "Allega ({count})", + "pillRemove": "Rimuovi {title}", + "indexing": "Indicizzazione…", + "indexingHint": "Un documento è ancora in fase di indicizzazione; potrai inviare quando è pronto.", + "fencedHint": "Questa conversazione funziona senza accesso ai tuoi dati sanitari: il coach legge solo i documenti allegati.", + "documentsAttachedOne": "1 documento allegato", + "documentsAttachedOther": "{count} documenti allegati", + "railBadge": "Chat documento isolata", + "uploadError": "Caricamento non riuscito. Riprova." + } }, "relativeJustNow": "proprio ora", "relativeMinutesAgoOne": "{count} minuto fa", diff --git a/messages/pl.json b/messages/pl.json index 314f7ea7d..b057686a9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2450,7 +2450,27 @@ "correlationWeightWeekday": "Czy moja waga podlega cotygodniowemu wzorcowi? Co mówi mi ta korelacja według dnia tygodnia?", "recommendation": "Powiedz mi więcej o tej rekomendacji: \"{text}\" — dlaczego mnie dotyczy i co powinienem z nią zrobić?" }, - "assessmentPrompt": "Wyjaśnij mi moją ocenę {metric} — co oznacza i co powinienem z tym zrobić?" + "assessmentPrompt": "Wyjaśnij mi moją ocenę {metric} — co oznacza i co powinienem z tym zrobić?", + "attach": { + "menuChooseFromDocuments": "Wybierz z dokumentów", + "menuUploadNew": "Prześlij nowy dokument", + "trigger": "Załącz dokumenty", + "pickerTitle": "Załącz dokumenty", + "pickerDescription": "Coach czyta tylko załączone dokumenty, a nie Twoje dane zdrowotne.", + "pickerSearchPlaceholder": "Szukaj dokumentów…", + "pickerEmpty": "Nie masz jeszcze dokumentów w archiwum.", + "pickerNotIndexed": "Jeszcze nie zindeksowano", + "pickerCap": "Możesz załączyć jeszcze do {max}.", + "pickerConfirm": "Załącz ({count})", + "pillRemove": "Usuń {title}", + "indexing": "Indeksowanie…", + "indexingHint": "Dokument jest jeszcze indeksowany – wyślesz, gdy będzie gotowy.", + "fencedHint": "Ta rozmowa działa bez dostępu do Twoich danych zdrowotnych – coach czyta tylko załączone dokumenty.", + "documentsAttachedOne": "1 załączony dokument", + "documentsAttachedOther": "Załączone dokumenty: {count}", + "railBadge": "Odizolowany czat dokumentu", + "uploadError": "Przesyłanie nie powiodło się. Spróbuj ponownie." + } }, "relativeJustNow": "przed chwilą", "relativeMinutesAgoOne": "{count} minutę temu", diff --git a/package.json b/package.json index 500802a1a..a497fa982 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.55", + "version": "1.28.56", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0245_coach_conversation_documents/migration.sql b/prisma/migrations/0245_coach_conversation_documents/migration.sql new file mode 100644 index 000000000..3e95578c3 --- /dev/null +++ b/prisma/migrations/0245_coach_conversation_documents/migration.sql @@ -0,0 +1,71 @@ +-- S7: attach multiple documents to a coach conversation. +-- +-- Replaces the scalar `coach_conversations.document_id` discriminator (which +-- could express only ONE document and would UNFENCE a conversation when that +-- document was deleted) with a join table + a sticky `document_scoped` flag. +-- +-- The flag is the fail-closed half of the fenced-only discriminator: it is set +-- true at fenced creation / first attach and NEVER cleared. The join table +-- holds the LIVE attachment set. A count-only predicate would let a deleted +-- attachment flip a conversation (whose history may contain document-derived +-- text) back onto the tool loop; the sticky flag makes "fenced" a one-way latch +-- independent of attachment liveness. +-- +-- Backfill runs in the SAME transaction as the DDL (Postgres allows it) BEFORE +-- the scalar column is dropped, so there is no dual-source-of-truth window: +-- every legacy single-doc chat becomes a one-attachment fenced conversation. + +-- CreateTable +CREATE TABLE "coach_conversation_documents" ( + "conversation_id" TEXT NOT NULL, + "document_id" TEXT NOT NULL, + "added_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "coach_conversation_documents_pkey" PRIMARY KEY ("conversation_id", "document_id") +); + +-- CreateIndex — the document-sheet "chats about this doc" scan + the path-id +-- join filter the single-doc route uses. +CREATE INDEX "coach_conversation_documents_document_id_idx" + ON "coach_conversation_documents" ("document_id"); + +-- AddForeignKey — deleting a conversation cascades its attachment rows away. +ALTER TABLE "coach_conversation_documents" + ADD CONSTRAINT "coach_conversation_documents_conversation_id_fkey" + FOREIGN KEY ("conversation_id") REFERENCES "coach_conversations" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey — deleting a document cascades its join rows away (its text +-- leaves every conversation's context) but NEVER the conversation itself. +ALTER TABLE "coach_conversation_documents" + ADD CONSTRAINT "coach_conversation_documents_document_id_fkey" + FOREIGN KEY ("document_id") REFERENCES "inbound_documents" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +-- AlterTable — the sticky fence flag. +ALTER TABLE "coach_conversations" + ADD COLUMN "document_scoped" BOOLEAN NOT NULL DEFAULT false; + +-- Backfill: every legacy single-doc chat becomes a one-attachment fenced +-- conversation. Runs BEFORE the DROP COLUMN below. +INSERT INTO "coach_conversation_documents" ("conversation_id", "document_id", "added_at") + SELECT "id", "document_id", "created_at" + FROM "coach_conversations" + WHERE "document_id" IS NOT NULL; + +UPDATE "coach_conversations" SET "document_scoped" = true + WHERE "document_id" IS NOT NULL; + +-- The scalar discriminator goes away in the SAME migration — no dual-source-of- +-- truth window, no "in case" shim. Every reader is repointed in the same +-- release. Drop the FK + covering index first, then the column. +ALTER TABLE "coach_conversations" + DROP CONSTRAINT "coach_conversations_document_id_fkey"; + +DROP INDEX "coach_conversations_user_id_document_id_updated_at_idx"; + +ALTER TABLE "coach_conversations" + DROP COLUMN "document_id"; + +-- CreateIndex — the rail + tool-route-send predicate on the sticky flag. +CREATE INDEX "coach_conversations_user_id_document_scoped_updated_at_idx" + ON "coach_conversations" ("user_id", "document_scoped", "updated_at"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index eca80c5a9..ba24b0d8e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4657,27 +4657,53 @@ model CoachConversation { summaryUpdatedAt DateTime? @map("summary_updated_at") summaryTurnCount Int @default(0) @map("summary_turn_count") - // v1.27.33 (Document vault P4 — chat about a document) — the discriminator - // that turns a Coach conversation into a DOCUMENT chat. NULL = a normal Coach - // conversation (health-record surface, snapshot + tools). Non-NULL = a chat - // scoped to exactly one stored document: its only context is that document's - // text, no health snapshot, no tools. The two never mix — the Coach rail - // filters `documentId: null` and the document sheet filters `documentId: `. - // Cascades on document delete so a discarded document takes its chats with it. - documentId String? @map("document_id") + // v1.29.x (S7 — attach multiple documents to a coach conversation) — PERMANENT + // fence marker. Set true when the conversation is created via the fenced + // endpoint or when the FIRST document is attached. NO code path ever sets it + // back to false — detaching every document, or a document being deleted, + // leaves it true. A conversation whose history may contain document-derived + // text must never regain the tool loop. The LIVE attachment set lives in the + // `CoachConversationDocument` join table; this sticky flag is the fail-closed + // half of the fenced-only discriminator (see the dual predicate on the tool + // route). Replaces the pre-S7 scalar `documentId` discriminator, which could + // only express one document and would UNFENCE on that document's delete. + documentScoped Boolean @default(false) @map("document_scoped") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - document InboundDocument? @relation(fields: [documentId], references: [id], onDelete: Cascade) - messages CoachMessage[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + messages CoachMessage[] + attachments CoachConversationDocument[] @@index([userId, updatedAt]) - // v1.27.33 — the document sheet lists a document's chats newest-first; the - // Coach rail lists `documentId IS NULL` newest-first. This composite covers - // both scans without a separate index per surface. - @@index([userId, documentId, updatedAt]) + // v1.29.x (S7) — the rail lists health threads + fenced threads newest-first + // and the tool-route send path filters on the flag. Replaces the retired + // [userId, documentId, updatedAt] composite. + @@index([userId, documentScoped, updatedAt]) @@map("coach_conversations") } +/// v1.29.x (S7) — a document attached to a coach conversation. The EXISTENCE of +/// any row for a conversation is one half of the fenced-only discriminator (the +/// other is `CoachConversation.documentScoped`). Composite PK so the SQL +/// backfill needs no client-side id generation and duplicate attaches are +/// impossible by construction. Two cascades: +/// - from DOCUMENT: deleting a vault document removes its join rows (its text +/// leaves every conversation's context) but NEVER the conversation — a +/// fenced thread survives via the sticky flag. +/// - from CONVERSATION: deleting a chat removes its attachment rows, never the +/// documents. +model CoachConversationDocument { + conversationId String @map("conversation_id") + documentId String @map("document_id") + addedAt DateTime @default(now()) @map("added_at") + + conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) + document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) + + @@id([conversationId, documentId]) + @@index([documentId]) // document-sheet "chats about this doc" scan + path-id join filter + @@map("coach_conversation_documents") +} + // One message in a Coach conversation. `role` is "user" or "assistant" // — same shape as the OpenAI/Anthropic chat-completion APIs. // @@ -6069,10 +6095,11 @@ model InboundDocument { /// column) so the list/detail `omit: { contentEncrypted }` queries never /// drag the blob into the SELECT. Cascades on delete. thumbnail DocumentThumbnail? - /// v1.27.33 (Document vault P4) — "chat about a document" threads. Each is a - /// `CoachConversation` with `documentId` set to this document; cascades on - /// delete so a discarded document takes its chats with it. - chats CoachConversation[] + /// v1.29.x (S7) — coach-conversation attachments (m:n via + /// `CoachConversationDocument`). Deleting this document cascades its join rows + /// away (its text leaves every conversation's context) but never the + /// conversation itself — a fenced thread survives via its sticky flag. + chatAttachments CoachConversationDocument[] /// share-link memberships (m:n via `ClinicianShareLinkDocument`). /// Cascades: deleting this document removes it from every share it was on. shareLinks ClinicianShareLinkDocument[] diff --git a/public/sw.js b/public/sw.js index e89f2e39d..21f679d40 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.28.55"; + /* @sw-version-fallback */ "v1.28.56"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts index 0cc58f8f8..b177af231 100644 --- a/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts @@ -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(); }); diff --git a/src/app/api/documents/inbound/[id]/chat/route.ts b/src/app/api/documents/inbound/[id]/chat/route.ts index 58779219e..4a001abad 100644 --- a/src/app/api/documents/inbound/[id]/chat/route.ts +++ b/src/app/api/documents/inbound/[id]/chat/route.ts @@ -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 = { - "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 { - 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 { - 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 { - 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, }); diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index 17dd78f64..be689bb62 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -112,6 +112,17 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray = [ "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")'; diff --git a/src/app/api/insights/chat/[id]/attachments/[documentId]/route.ts b/src/app/api/insights/chat/[id]/attachments/[documentId]/route.ts new file mode 100644 index 000000000..f4da8574a --- /dev/null +++ b/src/app/api/insights/chat/[id]/attachments/[documentId]/route.ts @@ -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 }); + }, +); diff --git a/src/app/api/insights/chat/[id]/attachments/__tests__/route.test.ts b/src/app/api/insights/chat/[id]/attachments/__tests__/route.test.ts new file mode 100644 index 000000000..03dfd5bb2 --- /dev/null +++ b/src/app/api/insights/chat/[id]/attachments/__tests__/route.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/app/api/insights/chat/[id]/attachments/route.ts b/src/app/api/insights/chat/[id]/attachments/route.ts new file mode 100644 index 000000000..2b1dcd488 --- /dev/null +++ b/src/app/api/insights/chat/[id]/attachments/route.ts @@ -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 }); + }, +); diff --git a/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts b/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts new file mode 100644 index 000000000..e187cc5b4 --- /dev/null +++ b/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts @@ -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: 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; + +function chatReq(body: Record): 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): Promise { + 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" }, + }), + ); + }); +}); diff --git a/src/app/api/insights/chat/fenced/__tests__/route.test.ts b/src/app/api/insights/chat/fenced/__tests__/route.test.ts new file mode 100644 index 000000000..ce56b9594 --- /dev/null +++ b/src/app/api/insights/chat/fenced/__tests__/route.test.ts @@ -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 { + 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(); + }); +}); diff --git a/src/app/api/insights/chat/fenced/route.ts b/src/app/api/insights/chat/fenced/route.ts new file mode 100644 index 000000000..338c938c1 --- /dev/null +++ b/src/app/api/insights/chat/fenced/route.ts @@ -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, + }); +}); diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index a347ba82a..7462ed30a 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -304,22 +304,39 @@ async function handleChatRequest(request: NextRequest): Promise { 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; diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index 1607186c0..50a3dae50 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -26,6 +26,10 @@ import { withIdempotency } from "@/lib/idempotency"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; import { serialiseLabResult } from "@/lib/labs/serialise"; +import { + linkOcrLabsToVaultDocument, + type InsertedLabForLink, +} from "@/lib/labs/vault-link"; import { annotate } from "@/lib/logging/context"; import { ocrCommitSchema, @@ -121,6 +125,9 @@ async function commitOcrRows(request: NextRequest) { const inserted: ReturnType[] = []; const skipped: OcrSkippedRowDto[] = []; + // S9 — the rows actually written, kept for the vault cross-link so a re-commit + // (every row a duplicate → nothing inserted) links nothing new. + const linkable: InsertedLabForLink[] = []; // Tracks the keys already written in THIS request so an in-document duplicate // is caught even before the prior row is visible to a live query. The // mint-then-create on a mid-row failure can leave an orphan biomarker, which @@ -165,6 +172,28 @@ async function commitOcrRows(request: NextRequest) { writtenInBatch.add(key); inserted.push(serialiseLabResult(created, biomarker)); + linkable.push({ + labResultId: created.id, + analyte: created.analyte, + value: created.value, + valueText: created.valueText, + unit: created.unit, + referenceLow: created.referenceLow, + referenceHigh: created.referenceHigh, + takenAt: created.takenAt, + }); + } + + // S9 — cross-link the freshly inserted labs to the vault document the client + // filed the scanned bytes into. Best-effort: the labs are the authoritative + // write, so a link failure (module off, foreign / missing document) never + // fails the commit. Owner + module checks live inside the linker. + if (parsed.data.documentId && linkable.length > 0) { + await linkOcrLabsToVaultDocument( + user.id, + parsed.data.documentId, + linkable, + ).catch(() => {}); } await auditLog("labs.ocr.commit", { diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index 1f5242bd3..a80a3d776 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -3,7 +3,13 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { FileText, MessagesSquare, Search, Target, Trash2 } from "lucide-react"; +import { + MessagesSquare, + Paperclip, + Search, + Target, + Trash2, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -224,13 +230,24 @@ function CoachConversationsBody() { data-slot="coach-conversations-select" > - {/* v1.28.51 — badge doc-scoped threads (fenced - endpoint) so they read as distinct in the list. */} - {c.documentId ? ( - + {/* v1.29.x (S7) — badge fenced threads (they run on + the hardened fenced endpoint over their attached + documents) with a paperclip + attachment count. */} + {c.fenced ? ( + + ) : null} {c.title} diff --git a/src/components/documents/__tests__/document-coach-launch-wiring.test.ts b/src/components/documents/__tests__/document-coach-launch-wiring.test.ts new file mode 100644 index 000000000..f0b59d9ce --- /dev/null +++ b/src/components/documents/__tests__/document-coach-launch-wiring.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * v1.28.52 (Documents R3) — "Ask the Coach" opens the REAL fenced coach + * conversation in the shared SIDE DRAWER, scoped to the document, instead of a + * full-page nav to `/coach?doc=` (the v1.28.51 behaviour Marc corrected). + * + * The wiring is source-structural (the send-path fence itself is covered by the + * coach-send-target unit tests — untouched here). These guards pin: + * 1. the detail sheet drives the drawer via `askCoach(..., doc.id)`, closing + * the sheet first, and no longer navigates to `/coach?doc=`; + * 2. the launch context → mount → drawer → conversation `documentId` thread + * stays connected end-to-end. + */ +const ROOT = join(__dirname, "../../../.."); +const DETAIL_SHEET = join( + ROOT, + "src/components/documents/document-detail-sheet.tsx", +); +const MOUNT = join(ROOT, "src/components/insights/layout-coach-mount.tsx"); +const DRAWER = join( + ROOT, + "src/components/insights/coach-panel/coach-drawer.tsx", +); + +function load(path: string): string { + return readFileSync(path, "utf8"); +} + +describe("document detail sheet — Ask the Coach opens the drawer", () => { + const src = load(DETAIL_SHEET); + + it("launches the doc-scoped coach drawer via askCoach(..., doc.id)", () => { + expect(src).toContain("useCoachLaunch"); + expect(src).toMatch(/askCoach\(null,\s*undefined,\s*false,\s*doc\.id\)/); + }); + + it("closes the detail sheet when handing off to the drawer", () => { + // The Ask-the-Coach onClick closes the sheet before opening the drawer so + // the drawer owns the surface (no stacked dialogs). + expect(src).toMatch( + /onClick=\{\(\)\s*=>\s*\{[\s\S]*?onOpenChange\(false\);[\s\S]*?askCoach\(null,\s*undefined,\s*false,\s*doc\.id\)/, + ); + }); + + it("no longer navigates to the full-page /coach?doc= route", () => { + expect(src).not.toContain("/coach?doc="); + expect(src).not.toContain('from "next/navigation"'); + }); +}); + +describe("documentId threads launch context → mount → drawer", () => { + it("the mount forwards launch.documentId to the drawer", () => { + expect(load(MOUNT)).toContain("documentId={launch.documentId}"); + }); + + it("the drawer seeds CoachConversation with initialDocumentId + a conversation-id getter", () => { + const src = load(DRAWER); + expect(src).toContain("initialDocumentId={documentId}"); + expect(src).toContain( + "registerConversationIdGetter={registerConversationIdGetter}", + ); + }); +}); diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 7320df94e..4b0515e58 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -28,7 +28,6 @@ import { X, } from "lucide-react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; @@ -59,6 +58,7 @@ import { apiPost, } from "@/lib/api/api-fetch"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; import { @@ -216,7 +216,11 @@ export function DocumentDetailSheet({ const { t, locale } = useTranslations(); const format = useFormatters(); const queryClient = useQueryClient(); - const router = useRouter(); + // v1.28.52 (Documents R3) — the vault "Ask the Coach" action opens the REAL + // fenced coach conversation in the shared side drawer (scoped to this + // document) instead of a full-page nav; `askCoach(..., doc.id)` carries the + // document scope through the launch context. + const launch = useCoachLaunch(); const detail = useQuery({ queryKey: queryKeys.inboundDocument(documentId ?? "none"), @@ -472,14 +476,19 @@ export function DocumentDetailSheet({
{aiEnabled ? ( - // v1.28.51 (Documents R3, Design A) — "Ask the Coach" opens - // the REAL coach view scoped to this document - // (`/coach?doc=`), replacing the bespoke doc-chat drawer. - // Every doc turn there still runs on the hardened fenced - // document endpoint (never the coach tool route). + // v1.28.52 (Documents R3) — "Ask the Coach" opens the REAL + // coach conversation scoped to this document in the SIDE + // DRAWER (the maximize control there expands it to the full + // `/coach` page). Every doc turn still runs on the hardened + // fenced document endpoint (never the coach tool route) — only + // WHERE the conversation renders changed, not the send path. + // Close the detail sheet so the drawer owns the surface.