From cafacdd367cc56a0d73713bde4979dfba995f51f Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 17 Sep 2026 18:49:33 -0700 Subject: [PATCH 1/3] fix(annotate): scope the framed 404 to paths that name a file #1561's catch-all guard fired on every framed request, including the app document at `/`. The VS Code extension frames the session URL, so an annotate session opened from the editor rendered "404 Not found" instead of the app. One shared predicate (pathNamesEmbeddedDocument / isFramedEmbeddedDocumentRequest in packages/shared/html-assets.ts, vendored to Pi) now decides it on the SHAPE OF THE PATH, and the Pi mirror uses it instead of its inline copy. --- apps/pi-extension/server/serverAnnotate.ts | 13 +++--- packages/server/html-assets.ts | 17 +++++--- packages/shared/html-assets.ts | 51 ++++++++++++++++++++++ 3 files changed, 70 insertions(+), 11 deletions(-) diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts index dab9e78a6..5900b51a1 100644 --- a/apps/pi-extension/server/serverAnnotate.ts +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -72,7 +72,7 @@ import { encodeHtmlAssetPath, htmlAssetBaseHref, htmlAssetDocumentHeaders, - isFramedFetchDest, + isFramedEmbeddedDocumentRequest, resolveHtmlAssetRoute, rewriteHtmlAssetReferences, } from "../generated/html-assets.ts"; @@ -1164,11 +1164,14 @@ export async function startAnnotateServer(options: { await handleSaveNotesRequest(req, res); } else if (url.pathname.startsWith("/api/")) { handleApiNotFound(res, url.pathname); - } else if (isFramedFetchDest(firstHeader(req.headers["sec-fetch-dest"]))) { + } else if (isFramedEmbeddedDocumentRequest(firstHeader(req.headers["sec-fetch-dest"]), url.pathname)) { // Nested-document guard: a request the browser will render inside a - // frame must never receive the editor app. Relative embeds are - // anchored at their own directory by the asset-route , so - // anything reaching here names a file that genuinely is not there. + // frame AND whose path names a file must never receive the editor + // app. Relative embeds are anchored at their own directory by the + // asset-route , so anything reaching here names a file + // that genuinely is not there. The path condition keeps the app + // document itself (`/`, which is how the VS Code extension frames a + // session) out of the guard — see pathNamesEmbeddedDocument. const name = url.pathname.split("/").filter(Boolean).pop(); res.writeHead(404, htmlAssetDocumentHeaders(HTML_ASSET_ERROR_CSP)); res.end(buildHtmlAssetErrorDocument(404, "Not found", name)); diff --git a/packages/server/html-assets.ts b/packages/server/html-assets.ts index 15d313db0..852ebc827 100644 --- a/packages/server/html-assets.ts +++ b/packages/server/html-assets.ts @@ -7,7 +7,7 @@ import { encodeHtmlAssetPath, htmlAssetBaseHref, htmlAssetDocumentHeaders, - isFramedFetchDest, + isFramedEmbeddedDocumentRequest, resolveHtmlAssetRoute, rewriteHtmlAssetReferences, } from "@plannotator/shared/html-assets"; @@ -39,13 +39,18 @@ function assetError( /** * The catch-all's guard: a request the browser will render as a nested - * document must never receive the editor app. That is the bug this whole - * change is about — Plannotator rendering inside an annotated page's embed — - * and the `` fix removes the usual way of getting here, so anything - * still arriving is a genuinely missing file and deserves to say so. + * document, AND whose path names a file, must never receive the editor app. + * That is the bug this whole change is about — Plannotator rendering inside an + * annotated page's embed — and the `` fix removes the usual way of + * getting here, so anything still arriving is a genuinely missing file and + * deserves to say so. The path condition is what keeps the app document itself + * (`/`) out of it: see `pathNamesEmbeddedDocument` for why the shape of the + * path, and not `Sec-Fetch-Site`, is the signal. */ export function framedDocumentNotFound(req: Request, url: URL): Response | null { - if (!isFramedFetchDest(req.headers.get("sec-fetch-dest"))) return null; + if (!isFramedEmbeddedDocumentRequest(req.headers.get("sec-fetch-dest"), url.pathname)) { + return null; + } const name = url.pathname.split("/").filter(Boolean).pop(); return new Response(buildHtmlAssetErrorDocument(404, "Not found", name), { status: 404, diff --git a/packages/shared/html-assets.ts b/packages/shared/html-assets.ts index a03d4d8a1..4ba116b2c 100644 --- a/packages/shared/html-assets.ts +++ b/packages/shared/html-assets.ts @@ -48,6 +48,57 @@ export function isFramedFetchDest(secFetchDest: string | null | undefined): bool return typeof secFetchDest === "string" && FRAMED_FETCH_DESTS.has(secFetchDest.trim().toLowerCase()); } +/** + * A single path segment that names a file: `page.html`, `chart.svg`, `app.js`. + * Bounded on purpose — a long trailing dot-run in a slug (`v1.2.3-release`) is + * still a file shape, but an arbitrary tail is not worth treating as one. + */ +const FILE_NAME_SEGMENT = /\.[A-Za-z0-9][A-Za-z0-9_-]{0,9}$/; + +/** + * Could this path name a MISSING embedded document, as opposed to the app + * document itself? + * + * The annotate catch-all serves the editor app for every non-`/api` path, so + * without this the framed-404 guard answers 404 for the app root too — which is + * exactly how the VS Code extension loads a session (`panel-manager.ts` puts the + * session URL in an `