fix(02-12.1): reach fenced document routes through the app transport

A browser-issued <img src>, <iframe src> or <a href download> cannot
carry the record-session assertion, so the fence refused all three on
any session that had been inside a shared record — permanently, since
none of them will ever send a header.

- add a structural guard: no client file may name a record-fenced route
  in a src=, href= or window.location sink
- fetch the thumbnail, the inline preview and the download through the
  app transport and hand the DOM an object URL, revoked on unmount
- make the two API probes in the managed-profiles browser spec assert
  the same context the app beside them asserts
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 20:36:45 +02:00
1 parent 8fcbdcc0df
commit ffe38c9bb6
7 files changed
+739 -27

No files matched your search

@@ -118,7 +118,20 @@ describe("<DocumentCard>", () => {
expect(html).toContain("befund.docx");
});
it("renders the preview thumbnail image when hasThumbnail is set", () => {
it("never emits a browser-issued /api subresource for the thumbnail", () => {
// v1.37.0 — this used to assert `src="/api/documents/inbound/…/thumbnail"`
// in the markup, and that contract is deliberately gone. A `<img src>` is
// issued by the BROWSER, so it cannot carry the record-session assertion,
// and the fence refuses an unasserted request on any session that has been
// inside a shared record — a thumbnail that silently stops rendering for
// good. The bytes now come through the app transport as an object URL
// (`useFencedObjectUrl`), which means nothing paints on a server render.
//
// The assertion is inverted rather than deleted: the markup must contain no
// `/api/` reference at all. `src/__tests__/record-fence-headerless-transport-guard.test.ts`
// holds the same property across the whole client tree, and
// `src/hooks/__tests__/use-fenced-object-url.test.ts` proves the request
// really does carry the headers.
const html = render(
<DocumentCard
document={doc({ hasThumbnail: true })}
@@ -128,9 +141,11 @@ describe("<DocumentCard>", () => {
highlighted={false}
/>,
);
expect(html).toContain('data-slot="document-thumbnail"');
expect(html).toContain('src="/api/documents/inbound/doc-1/thumbnail"');
expect(html).toContain('loading="lazy"');
expect(html).not.toContain("/api/documents");
// Until the blob lands the card shows its kind icon, exactly as a card
// with no thumbnail does — never a broken image.
expect(html).not.toContain('data-slot="document-thumbnail"');
expect(html).toContain("lucide-scan-line");
});
it("shows the kind icon (no thumbnail image) when hasThumbnail is false", () => {
+24 -11
View File
@@ -23,6 +23,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { useFencedObjectUrl } from "@/hooks/use-fenced-object-url";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
@@ -75,6 +76,16 @@ export function DocumentCard({
// A preview thumbnail that fails to load (still rendering, decrypt error,
// 404) falls back to the kind icon — never a broken image.
const [thumbFailed, setThumbFailed] = useState(false);
// v1.37.0 — fetched through the app transport rather than issued by the
// browser as a subresource, so it carries the record-session assertion. The
// null path means "this card has no thumbnail, or one already failed", which
// is what keeps the timeline from firing a request per card for documents
// that have nothing to show.
const { url: thumbnailUrl, failed: thumbnailFailed } = useFencedObjectUrl(
document.hasThumbnail && !thumbFailed
? `/api/documents/inbound/${document.id}/thumbnail`
: null,
);
// Touch long-press selects instead of opening; the click that the
// browser fires after the release is swallowed once.
@@ -128,24 +139,26 @@ export function DocumentCard({
>
<CardContent className="flex h-full flex-col gap-2 px-4">
<div className="flex items-start gap-2">
{document.hasThumbnail && !thumbFailed ? (
// Leading-edge preview tile. `loading="lazy"` + the timeline's
// virtualization means only cards near the viewport fetch their
// thumbnail. Decorative (alt="") — the title beside it names the
// document; an authed same-origin subresource, never cached
// cross-user. onError falls back to the kind icon.
{thumbnailUrl && !thumbnailFailed ? (
// Leading-edge preview tile. Decorative (alt="") — the title
// beside it names the document. The laziness that `loading="lazy"`
// used to provide now lives in the hook's null path: a card with no
// thumbnail, or one whose thumbnail already failed, issues no
// request at all. onError falls back to the kind icon.
<span
data-slot="document-thumbnail"
className="bg-muted mt-0.5 block size-12 shrink-0 overflow-hidden rounded-md"
>
{/* Authed, private (no-store) same-origin subresource — next/image
would try to proxy/optimize it, which is wrong for a PHI blob
we deliberately never cache. */}
{/* An object URL over bytes the app transport already fetched,
not a `/api/…` subresource: the browser issues a subresource
itself and so cannot carry the record-session assertion, which
the fence refuses on any session that has been inside a shared
record. `next/image` is wrong here for the older reason too —
it would proxy a PHI blob we deliberately never cache. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`/api/documents/inbound/${document.id}/thumbnail`}
src={thumbnailUrl}
alt=""
loading="lazy"
decoding="async"
onError={() => setThumbFailed(true)}
className="size-full object-cover"
@@ -64,6 +64,7 @@ import {
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import { useFencedObjectUrl } from "@/hooks/use-fenced-object-url";
import { cn } from "@/lib/utils";
import {
INBOUND_DOCUMENT_KINDS,
@@ -105,7 +106,12 @@ function InlinePreview({
title: string;
}) {
const [loaded, setLoaded] = useState(false);
const src = `/api/documents/inbound/${documentId}/original`;
// v1.37.0 — the bytes come through the app transport, which carries the
// record-session assertion; a browser-issued `src="/api/…"` cannot, and the
// fence refuses it on any session that has been inside a shared record.
const { url: src, failed } = useFencedObjectUrl(
`/api/documents/inbound/${documentId}/original`,
);
const isPdf = mimeType === "application/pdf";
// Chromium's PDF viewer grabs focus while it initialises — BEFORE the
// frame's `load` event — stranding keyboard events inside the embedded
@@ -144,12 +150,23 @@ function InlinePreview({
return (
<div className="relative max-h-[55vh] overflow-auto overscroll-contain rounded-lg">
{!loaded ? (
{failed ? (
// The preview could not be fetched — refused, offline, or still
// rendering server-side. A permanent skeleton would read as "loading
// forever"; the sheet's own metadata and the download control below
// still work.
<p
data-slot="document-preview-unavailable"
className="text-muted-foreground py-8 text-center text-sm"
>
{title}
</p>
) : !loaded || src === null ? (
<Skeleton
className={cn("w-full rounded-lg", isPdf ? "h-[55vh]" : "h-64")}
/>
) : null}
{isPdf ? (
{src === null || failed ? null : isPdf ? (
// No `sandbox` attribute by design: Chromium force-downloads PDFs
// in sandboxed frames instead of rendering them. The serve
// response itself carries `default-src 'none'` +
@@ -487,7 +504,13 @@ export function DocumentDetailSheet({
const title = doc?.title ?? doc?.filename ?? t("documents.card.untitled");
const Icon = doc ? DOCUMENT_KIND_ICONS[doc.kind] : null;
const originalHref = doc ? `/api/documents/inbound/${doc.id}/original` : "#";
// v1.37.0 — an object URL over transport-fetched bytes rather than a bare
// `/api/…` href. A download navigation carries no record-session assertion,
// so the fence refuses it once the session has been inside a shared record.
// Null until the blob lands; the anchor is disabled rather than dead.
const { url: originalHref } = useFencedObjectUrl(
doc ? `/api/documents/inbound/${doc.id}/original` : null,
);
return (
<>
@@ -590,7 +613,11 @@ export function DocumentDetailSheet({
data-slot="document-download"
aria-label={t("documents.detail.download")}
>
<a href={originalHref} download={doc.filename ?? undefined}>
<a
href={originalHref ?? "#"}
download={doc.filename ?? undefined}
aria-disabled={originalHref === null}
>
<Download className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.detail.download")}
@@ -637,7 +664,11 @@ export function DocumentDetailSheet({
{formatBytes(doc.byteSize, locale)}
</p>
<Button asChild size="sm" className="mt-1">
<a href={originalHref} download={doc.filename ?? undefined}>
<a
href={originalHref ?? "#"}
download={doc.filename ?? undefined}
aria-disabled={originalHref === null}
>
<Download className="size-4" aria-hidden />
{t("documents.detail.download")}
</a>