From 749a4920a881b37211996cee0cb6ad58054b04fe Mon Sep 17 00:00:00 2001 From: kh0pper Date: Sun, 13 Sep 2026 19:39:21 -0500 Subject: [PATCH] =?UTF-8?q?feat(pwa):=20dashboard=20PWA=20parity=20polish?= =?UTF-8?q?=20=E2=80=94=20scoped=20manifest,=20PNG+maskable=20icons,=20SW?= =?UTF-8?q?=20auth/SSE=20bypass=20guards=20(PR-F,=20audit=20item=2022)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard was already installable (bb1cb865: manifest + sw + push). This closes the measured gaps against pi-lab's installable mobile page: - manifest: scope /dashboard (+id) so the installed app stops claiming the whole origin; 192/512 PNG icons and 512 maskable variants (rasterized from crow-icon.svg via rsvg-convert; maskable ships full-bleed square bg with art at 0.78 inside the safe zone); categories. - layout head: apple-mobile-web-app-title 'Crow'; apple-touch-icon now points at a 180px PNG — iOS renders an SVG touch icon as a blank tile. - sw.js: explicit pass-through (no respondWith) for /dashboard/perch-api/* and any eventsource / text/event-stream request — authed JSON never touches a cache path and SSE keeps native browser streaming instead of the SW fetch pipe. Bumped crow-v1 -> crow-v2. - tests/dashboard-pwa.test.js: 5 static-asset pins incl. bypass ordering (perch bypass must precede the /dashboard/ branch) and return-shape. Funnel invariant untouched: no enforcement-layer file changed; /sw.js stays off PUBLIC_FUNNEL_PREFIXES (measured 403 via funnel header). --- servers/gateway/dashboard/shared/layout.js | 5 +- .../gateway/public/icons/apple-touch-icon.png | Bin 0 -> 4608 bytes .../public/icons/crow-icon-maskable.svg | 14 ++++ servers/gateway/public/icons/icon-192.png | Bin 0 -> 4914 bytes servers/gateway/public/icons/icon-512.png | Bin 0 -> 13975 bytes .../public/icons/icon-maskable-192.png | Bin 0 -> 2788 bytes .../public/icons/icon-maskable-512.png | Bin 0 -> 7816 bytes servers/gateway/public/manifest.json | 9 ++- servers/gateway/public/sw.js | 15 +++- tests/dashboard-pwa.test.js | 73 ++++++++++++++++++ 10 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 servers/gateway/public/icons/apple-touch-icon.png create mode 100644 servers/gateway/public/icons/crow-icon-maskable.svg create mode 100644 servers/gateway/public/icons/icon-192.png create mode 100644 servers/gateway/public/icons/icon-512.png create mode 100644 servers/gateway/public/icons/icon-maskable-192.png create mode 100644 servers/gateway/public/icons/icon-maskable-512.png create mode 100644 tests/dashboard-pwa.test.js diff --git a/servers/gateway/dashboard/shared/layout.js b/servers/gateway/dashboard/shared/layout.js index 77b8c329..35c8c48a 100644 --- a/servers/gateway/dashboard/shared/layout.js +++ b/servers/gateway/dashboard/shared/layout.js @@ -223,8 +223,11 @@ export function renderLayout({ title, content, activePanel, panels, scripts, aft + - + + ${FONT_LINKS} ${dashboardCss()} ${turboHead()} diff --git a/servers/gateway/public/icons/apple-touch-icon.png b/servers/gateway/public/icons/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9dbf1f9f7267420508a59b6f8fb7b380f4e09aa2 GIT binary patch literal 4608 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 literal 0 HcmV?d00001 diff --git a/servers/gateway/public/icons/crow-icon-maskable.svg b/servers/gateway/public/icons/crow-icon-maskable.svg new file mode 100644 index 00000000..359eb8d2 --- /dev/null +++ b/servers/gateway/public/icons/crow-icon-maskable.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/servers/gateway/public/icons/icon-192.png b/servers/gateway/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..cb97868d0a2e608912ce9dcf8fa3045157d2274f GIT binary patch literal 4914 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 E52@Jr_y7O^ literal 0 HcmV?d00001 diff --git a/servers/gateway/public/icons/icon-512.png b/servers/gateway/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d804f137fe8a3a19bd89ae840adf556852fe2e1a GIT binary patch literal 13975 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 literal 0 HcmV?d00001 diff --git a/servers/gateway/public/manifest.json b/servers/gateway/public/manifest.json index ddb7093e..35cda290 100644 --- a/servers/gateway/public/manifest.json +++ b/servers/gateway/public/manifest.json @@ -1,13 +1,20 @@ { + "id": "/dashboard", "name": "Crow's Nest", "short_name": "Crow", "description": "AI-powered personal platform", "start_url": "/dashboard/nest", + "scope": "/dashboard", "display": "standalone", "background_color": "#1d1d1f", "theme_color": "#1d1d1f", "orientation": "any", + "categories": ["productivity", "developer tools"], "icons": [ - { "src": "/icons/crow-icon.svg", "sizes": "any", "type": "image/svg+xml" } + { "src": "/icons/crow-icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }, + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, + { "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ] } diff --git a/servers/gateway/public/sw.js b/servers/gateway/public/sw.js index c4023665..c870c529 100644 --- a/servers/gateway/public/sw.js +++ b/servers/gateway/public/sw.js @@ -5,7 +5,7 @@ * Handles push events and notification clicks. */ -const CACHE_NAME = "crow-v1"; +const CACHE_NAME = "crow-v2"; const SHELL_ASSETS = ["/dashboard/nest"]; self.addEventListener("install", (e) => { @@ -40,6 +40,19 @@ self.addEventListener("fetch", (e) => { try { reqOrigin = new URL(url).origin; } catch { reqOrigin = null; } if (reqOrigin && reqOrigin !== self.location.origin) return; + // PR-F explicit guard (2026-09-13): the perch interactive API is + // authenticated JSON, and its /events route is SSE. Never intercept + // either. Authed responses must not touch any cache path — and even + // respondWith(fetch()) on an event-stream route pipes the stream + // through the SW's fetch layer instead of the browser's native + // EventSource handling. No respondWith at all keeps both exactly as + // if this worker were not there. + if (url.includes("/dashboard/perch-api/")) return; + if (e.request.destination === "eventsource") return; + try { + if ((e.request.headers.get("accept") || "").includes("text/event-stream")) return; + } catch { /* header read on an odd request — let the branches below decide */ } + // Special case: /dashboard/nest is pre-cached for offline shell loading if (url.endsWith("/dashboard/nest")) { e.respondWith(fetch(e.request).catch(() => caches.match(e.request))); diff --git a/tests/dashboard-pwa.test.js b/tests/dashboard-pwa.test.js new file mode 100644 index 00000000..c5757754 --- /dev/null +++ b/tests/dashboard-pwa.test.js @@ -0,0 +1,73 @@ +/** + * PR-F — dashboard PWA shell integrity (perch parity audit item 22 polish, + * 2026-09-13). The dashboard was already installable (bb1cb865 shipped the + * manifest + SW + push); this file pins the parity gaps closed since: + * + * 1. manifest: scoped to /dashboard, id, real 192/512 PNG icons + a + * maskable variant, every referenced icon file actually ships; + * 2. sw.js: the authenticated perch API and the SSE /events stream pass + * through with NO interception (never cache authed responses; never + * pipe an event stream through respondWith); + * 3. the rendered dashboard head carries apple-mobile-web-app-title and a + * PNG apple-touch-icon (iOS renders an SVG touch icon as a blank tile). + * + * All static-asset assertions — no server, no DB. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +const REPO = new URL("..", import.meta.url).pathname.replace(/\/$/, ""); +const PUB = join(REPO, "servers/gateway/public"); +const manifest = JSON.parse(readFileSync(join(PUB, "manifest.json"), "utf8")); + +test("manifest: standalone app scoped to the dashboard, start_url inside scope", () => { + assert.equal(manifest.display, "standalone"); + assert.equal(manifest.scope, "/dashboard"); + assert.equal(manifest.start_url, "/dashboard/nest"); + assert.ok(manifest.start_url.startsWith(manifest.scope), "start_url must live inside scope"); + assert.equal(manifest.id, "/dashboard"); +}); + +test("manifest: real PNG sizes plus a maskable variant (pi-lab parity)", () => { + const pngs = manifest.icons.filter((i) => i.type === "image/png"); + assert.ok(pngs.some((i) => i.sizes === "192x192"), "a 192px PNG icon"); + assert.ok(pngs.some((i) => i.sizes === "512x512"), "a 512px PNG icon"); + const maskable = pngs.filter((i) => (i.purpose || "").split(/\s+/).includes("maskable")); + assert.ok(maskable.some((i) => i.sizes === "512x512"), "a 512px maskable PNG icon"); +}); + +test("manifest: every referenced icon file ships under public/", () => { + for (const icon of manifest.icons) { + assert.ok(existsSync(join(PUB, icon.src)), icon.src + " must exist under servers/gateway/public/"); + } +}); + +test("sw.js: perch-api and SSE routes pass through, unintercepted, before any dashboard branch", () => { + const sw = readFileSync(join(PUB, "sw.js"), "utf8"); + const perch = sw.indexOf('"/dashboard/perch-api/"'); + assert.ok(perch > 0, "explicit /dashboard/perch-api/ bypass present"); + assert.ok(/destination === "eventsource"/.test(sw), "eventsource-destination bypass present"); + assert.ok(/text\/event-stream/.test(sw), "Accept: text/event-stream bypass present"); + // The perch routes live under the /dashboard/ substring too — a bypass + // ordered AFTER that branch would never run. + const dashBranch = sw.indexOf('includes("/dashboard/")'); + assert.ok(dashBranch > 0, "the dashboard network-only branch still exists"); + assert.ok(perch < dashBranch, "the bypasses precede the dashboard branch"); + // No respondWith may sit between a bypass return and the next branch — + // guard the shape, not just the strings: every bypass line ends in return. + for (const line of sw.slice(perch - 400, dashBranch).split("\n")) { + if (/perch-api|eventsource|event-stream/.test(line) && !line.trim().startsWith("//")) { + assert.match(line, /return;?\s*$/, "bypass must return without respondWith: " + line.trim()); + } + } +}); + +test("rendered head: install metas — apple title + PNG apple-touch-icon", async () => { + const { renderLayout } = await import("../servers/gateway/dashboard/shared/layout.js"); + const html = renderLayout({ title: "PWA test", content: "

x

", activePanel: "nest", panels: [], scripts: "" }); + assert.ok(html.includes('')); + assert.ok(html.includes('rel="apple-touch-icon" href="/icons/apple-touch-icon.png"')); + assert.ok(html.includes('rel="manifest" href="/manifest.json"')); +});