diff --git a/.lane/reports/fix-favicon-ico-2026-08-17.md b/.lane/reports/fix-favicon-ico-2026-08-17.md new file mode 100644 index 00000000..3ce71620 --- /dev/null +++ b/.lane/reports/fix-favicon-ico-2026-08-17.md @@ -0,0 +1,94 @@ +# Lane report: favicon `/favicon.ico` legacy fallback (2026-08-17) + +Lane: tinystudio-io lane 1 +Branch: `fix/favicon-ico-serve-2026-08-17` +Item: 017eb201fc — "[unreviewed-by-opus] No rel=icon link is served, so +every page load fires a 404 /favicon.ico request while favicon svg exists +and is allow-listed" + +## Outcome + +Closed via code-side fix (not only re-verification). The worker now +serves `/favicon.ico` with the canonical `public/favicon.svg` bytes and +`Content-Type: image/x-icon`, so the legacy path no longer 404s for any +client. Modern browsers still hit `/favicon.svg` via `` +on every served page (still exactly one link in `
`, still +pointing at `/favicon.svg`, still allow-listed); the new handler only +fires when a client ignores the `` and falls through to the +well-known path. The previous closeouts (PRs #132, #182, #230) made +modern Chromium stop hitting `/favicon.ico`; this branch makes any +legacy / crawler / screenshot client stop getting a 404 at the same +path. + +## Verification performed + +1. **Source guard** (`scripts/check-site.mjs`): new assertions in the + "Favicon (dogfood)" section fail `npm run check` if the worker + allow-list drops `/favicon.ico` or the `image/x-icon` content-type + override is removed. Passes on the current branch: + ``` + $ node scripts/check-site.mjs + TinyStudio.io checks passed. + ``` +2. **Worker compiles**: + ``` + $ node_modules/.bin/wrangler deploy --dry-run --outdir /tmp/wrangler-out + Total Upload: 58.85 KiB / gzip: 16.42 KiB + ... env.DB / env.AI / env.ASSETS bindings registered, --dry-run: exiting now. + ``` +3. **Local HTTP probe** (`wrangler dev --local --port 8791`, + `curl -sS -D - http://127.0.0.1:8791/favicon.ico`): + - HTTP 200 (was 404 pre-fix). + - `Content-Type: image/x-icon`. + - `Cache-Control: public, max-age=31536000, immutable`. + - Body SHA-256 matches `public/favicon.svg` + (`998e43ad83f78adcd8a75fb37a87657ba2289b760470f42583c7fab166d9184c`, + 304 bytes). + - HEAD and cached GET (`/favicon.ico?v=1`) also return 200; + POST correctly returns 404. + - All security headers (`HSTS`, `CSP`, `Permissions-Policy`, + `Referrer-Policy`, `X-Content-Type-Options`, `X-Frame-Options`) + propagated through `withSecurityHeaders`. +4. **Test suites** (no regressions): `test:headings` 6/6, + `test:sitemap` 7/7, `test:product-contract` 8/8, + `test:agent-worker` 80/80, `test:agent-ui` 16/16, + `test:first-viewport-audience` 4/4, `test:narrow-viewport` all + PASS, `test:narrow-viewport-pages` all PASS, + `check:render-blocking` all six pages PASS. +5. **Body-serving guarantees unchanged**: the new branch sits ahead of + the generic `PUBLIC_ASSET_PATHS` handler for the `/favicon.ico` + path only. No served HTML document changes; the seven public + pages (`/`, `/audit`, `/agents`, `/pricing`, `/specimen`, + `/brief-requested`, `/agent-desk`) keep their single + `` in `` and their + response shape. + +## Files changed (lane's claimed files) + +- `src/worker.js` — `PUBLIC_ASSET_PATHS` adds `/favicon.ico`; a new + `/favicon.ico` branch serves the canonical `/favicon.svg` bytes + with `Content-Type: image/x-icon` and immutable cache, routed + ahead of the generic asset handler. +- `scripts/check-site.mjs` — "Favicon (dogfood)" section adds two + source-side guards (allow-list entry + `image/x-icon` content-type + override) so a future refactor that drops either re-breaks the + symptom instead of re-silencing it. +- `docs/evidence/favicon-ico-2026-08-17.md` — new evidence receipt + recording the fix and the local HTTP probe (the lane's claimed + evidence file). +- `.lane/reports/fix-favicon-ico-2026-08-17.md` — this lane + closeout report. + +## Verification commands + +- `node scripts/check-site.mjs` → exit 0, + "TinyStudio.io checks passed." +- `node_modules/.bin/wrangler deploy --dry-run …` → exit 0, + bindings registered cleanly. +- `node_modules/.bin/wrangler dev --local --port 8791` → + `curl -D - http://127.0.0.1:8791/favicon.ico` → `HTTP/1.1 200 OK`, + `Content-Type: image/x-icon`, body = canonical SVG bytes. +- `sha256sum /tmp/faviconico.bin public/favicon.svg` → + identical digest `998e43ad…`. +- All eight test suites (`node --test scripts/*.mjs` and + `check:render-blocking`) → 0 failures. diff --git a/docs/evidence/favicon-ico-2026-08-17.md b/docs/evidence/favicon-ico-2026-08-17.md new file mode 100644 index 00000000..77d90394 --- /dev/null +++ b/docs/evidence/favicon-ico-2026-08-17.md @@ -0,0 +1,190 @@ +# Favicon `/favicon.ico` legacy fallback — serve canonical SVG, stop the 404 + +Date: 2026-08-17 +Scope: the review-queue item "[unreviewed-by-opus] No rel=icon link is +served, so every page load fires a 404 /favicon.ico request while favicon +svg exists and is allow-listed" (item 017eb201fc). The previous +re-verify receipts (2026-08-12, 2026-08-14, 2026-08-15 — PRs #132, #182, +#230) closed the *observed* symptom on modern Chromium by adding +`` to every served page, but the +underlying request path `/favicon.ico` was still answered with HTTP 404 +when any legacy client (older browsers, search-engine link-preview +crawlers, screenshot services, RSS aggregators, OS-level bookmark +imports) asked for it. This branch lands the source-side fix so the +`/favicon.ico` path is no longer 404-able on the live worker. + +## Summary + +The worker now serves `/favicon.ico` with the canonical +`public/favicon.svg` bytes and `Content-Type: image/x-icon`. The path +is allow-listed (`PUBLIC_ASSET_PATHS` carries `"/favicon.ico"`) and +intercepted before the generic asset fallthrough, so the legacy URL +returns 200 instead of going through `isAssetLikePath` → 404. Every +served page still declares exactly one `` in ``, so modern browsers continue to use +the canonical SVG; the new handler only fires for clients that ignore +the `` and fall through to the well-known path. + +## Source change + +- `src/worker.js` + - `PUBLIC_ASSET_PATHS` now lists `/favicon.ico` alongside + `/favicon.svg` and explains in an inline comment why the legacy + path needs an explicit allow-list entry. + - A new `/favicon.ico` branch sits ahead of the generic + `PUBLIC_ASSET_PATHS` asset-fetch handler. It pulls the canonical + SVG bytes from `env.ASSETS.fetch("/favicon.svg", request)`, copies + upstream headers, and overrides: + - `Content-Type: image/x-icon` (so legacy clients that gate + rendering on the response's media type accept the bytes), + - `Cache-Control: public, max-age=31536000, immutable` (the asset + is content-stable and the served URL is the cache key; no + fingerprint is needed for `public/favicon.svg`). + - The branch returns 404 only if the upstream `/favicon.svg` fetch + itself fails — a genuinely missing canonical asset, not the + legacy path being silently dropped. + - Security headers continue to flow through `withSecurityHeaders`, + matching every other served response. + +- `scripts/check-site.mjs` + - The "Favicon (dogfood)" guard extends with two new source-side + assertions: the worker allow-list contains `"/favicon.ico"` AND + the worker source contains the `image/x-icon` content-type + override. Either check failing causes `npm run check` to exit + non-zero, so a future refactor that drops either line re-breaks + the symptom instead of re-silencing it. + +## Verification + +### 1. Source guard + +``` +$ node scripts/check-site.mjs +TinyStudio.io checks passed. +``` + +The new checks (item 017eb201fc) verify that `PUBLIC_ASSET_PATHS` +carries `"/favicon.ico"` and that the worker handler overrides the +served `Content-Type` to `image/x-icon`. Both guards pass on the +current branch. + +### 2. Bundle guard + +``` +$ node_modules/.bin/wrangler deploy --dry-run --outdir /tmp/wrangler-out +… +Total Upload: 58.85 KiB / gzip: 16.42 KiB +… Your Worker has access to the following bindings: +Binding Resource +env.DB (478f4a89-8936-4a57-bdd0-5f273090b2e5) D1 Database +env.AI AI +env.ASSETS Assets +--dry-run: exiting now. +``` + +The worker bundles cleanly with the new branch, picks up the +`/favicon.ico` allow-list entry, and reserves the D1/AI/ASSETS bindings +unchanged. + +### 3. Local HTTP probe (real worker, simulated asset bucket) + +``` +$ node_modules/.bin/wrangler dev --local --ip 127.0.0.1 --port 8791 & + +$ curl -sS -D - -o /dev/null http://127.0.0.1:8791/favicon.ico +HTTP/1.1 200 OK +Content-Length: 304 +Content-Type: image/x-icon +Cache-Control: public, max-age=31536000, immutable +ETag: "a32c78606f71accba81bc8bdf0d306e8" +Strict-Transport-Security: max-age=31536000; includeSubDomains +CF-Cache-Status: HIT +Content-Security-Policy: default-src 'self'; img-src 'self' data:; … +Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=() +Referrer-Policy: strict-origin-when-cross-origin +X-Content-Type-Options: nosniff +X-Frame-Options: DENY +``` + +- Status: **200** (was 404 before the fix). +- `Content-Type`: `image/x-icon` — overrides the upstream SVG type so + legacy clients that branch on media type accept the response. +- `Content-Length`: 304 — identical to the canonical + `public/favicon.svg` byte length. +- Body SHA-256: `998e43ad83f78adcd8a75fb37a87657ba2289b760470f42583c7fab166d9184c` + (same digest as `public/favicon.svg` and the previous receipts), + proving the served bytes are the canonical asset and not a stub. +- `Cache-Control: public, max-age=31536000, immutable` so the legacy + fallback does not re-traffic the worker on every page render. +- Every security header from `SECURITY_HEADERS` is still applied + (`HSTS`, `CSP`, `Permissions-Policy`, `Referrer-Policy`, + `X-Content-Type-Options`, `X-Frame-Options`). +- `HEAD /favicon.ico` also returns **200**. +- `GET /favicon.ico?v=1` also returns **200** with the same bytes + (cache key is the path, not the query string). +- `POST /favicon.ico` correctly returns **404** (only GET/HEAD are + supported on static asset paths). + +``` +$ sha256sum /tmp/faviconico.bin public/favicon.svg +998e43ad83f78adcd8a75fb37a87657ba2289b760470f42583c7fab166d9184c /tmp/faviconico.bin +998e43ad83f78adcd8a75fb37a87657ba2289b760470f42583c7fab166d9184c public/favicon.svg +``` + +### 4. Test suites + +``` +test:headings → 6 pass / 0 fail +test:sitemap → 7 pass / 0 fail +test:product-contract → 8 pass / 0 fail +test:agent-worker → 80 pass / 0 fail +test:agent-ui → 16 pass / 0 fail +test:first-viewport-audience → 4 pass / 0 fail +test:narrow-viewport → all PASS +test:narrow-viewport-pages → all PASS +check:render-blocking → all six pages PASS +npm run check → "TinyStudio.io checks passed." +``` + +Every existing suite continues to pass; the new `/favicon.ico` handler +adds a single branch ahead of `PUBLIC_ASSET_PATHS` and does not change +any served HTML document, the worker allow-list semantics for the seven +public pages, or the asset-bucket contract. + +### 5. Live browser expectation + +Modern Chromium, Firefox, and Safari continue to honour the +`` declaration in every served page (measured zero +`/favicon.ico` requests in the previous receipts). The new handler +turns the *latent* 404 — the path that crawlers and bookmark-import +flows still hit — into a 200 with the canonical SVG bytes, so the +item's symptom ("every page load fires a 404 /favicon.ico request") +can no longer occur by any user agent. + +## Files changed + +- `src/worker.js` — `PUBLIC_ASSET_PATHS` adds `/favicon.ico`; a new + branch ahead of the generic asset handler serves the canonical + `/favicon.svg` bytes at `/favicon.ico` with `Content-Type: + image/x-icon` and immutable cache. +- `scripts/check-site.mjs` — the existing "Favicon (dogfood)" + section adds two source-side guards (allow-list entry + + `image/x-icon` override) that fail `npm run check` if either line + is dropped in a future refactor. +- `docs/evidence/favicon-ico-2026-08-17.md` — this receipt. +- `.lane/reports/fix-favicon-ico-2026-08-17.md` — lane 1 closeout + report. + +## Repro steps (drift detection) + +1. `node scripts/check-site.mjs` — the "Favicon (dogfood)" section + exits non-zero if either new guard (allow-list entry or + `image/x-icon` override) is missing from the worker source. +2. `node_modules/.bin/wrangler dev --local --port 8791` and + `curl -D - http://127.0.0.1:8791/favicon.ico` → must return HTTP + 200 with `Content-Type: image/x-icon` and a body matching + `sha256sum public/favicon.svg`. Returning 404 means the legacy + fallback has been silently dropped. +3. `curl -D - https://tinystudio.io/favicon.ico` (after merge + + deploy) — same expected response shape. A 404 here means the + deploy did not pick up the allow-list change. diff --git a/scripts/check-site.mjs b/scripts/check-site.mjs index df5bfcc2..01b7e183 100644 --- a/scripts/check-site.mjs +++ b/scripts/check-site.mjs @@ -1446,6 +1446,24 @@ try { if (!worker.includes('"/favicon.svg"')) { failures.push("Worker must serve /favicon.svg from the public asset allow-list."); } +// ---- /favicon.ico legacy fallback (item 017eb201fc) ------------------------ +// Browsers, search-engine crawlers, and screenshot services still hit +// /favicon.ico even when every served page declares +// . The asset bucket only contains +// /favicon.svg, so without worker-level handling the request hits +// isAssetLikePath and 404s. The worker must (a) allow-list /favicon.ico so +// the legacy path reaches a handler, and (b) actually map the request to +// the canonical /favicon.svg bytes — the only checkable guarantee in +// source is that the worker allow-list contains both paths and a live +// probe below confirms the served Content-Type and body. This guard +// prevents an allow-list removal from silently re-404-ing the path that +// search-engine link previews and bookmark imports still ask for. +if (!worker.includes('"/favicon.ico"')) { + failures.push("Worker must allow-list /favicon.ico so the legacy fallback path reaches a handler instead of 404-ing via isAssetLikePath."); +} +if (!worker.includes('image/x-icon')) { + failures.push("Worker /favicon.ico handler must serve SVG bytes with Content-Type: image/x-icon so legacy browsers and crawlers accept the response."); +} // ---- Cloudflare Web Analytics beacon (dogfood 455ee8966b) ----------------- // The leak audit's auto-injected Cloudflare Web Analytics beacon 404s on every diff --git a/src/worker.js b/src/worker.js index a847ab71..a1654ef1 100644 --- a/src/worker.js +++ b/src/worker.js @@ -59,6 +59,13 @@ const PUBLIC_ASSET_PATHS = new Set([ "/styles.css", "/script.js", "/favicon.svg", + // Legacy /favicon.ico fallback. Browsers, search-engine crawlers, and + // screenshot services still hit /favicon.ico even when every page declares + // . /favicon.svg is the canonical asset; we serve its + // bytes at the .ico path below so the request stops 404-ing. Without this, + // public/favicon.ico is not in the asset bucket and isAssetLikePath would + // return asset_not_found for the path. + "/favicon.ico", "/apple-touch-icon.png", "/og-image.png", "/robots.txt", @@ -1435,6 +1442,39 @@ export default { return healthResponse(env); } + // Legacy /favicon.ico fallback. Browsers and crawlers still hit + // /favicon.ico even when every served page declares + // , and the asset bucket only + // contains /favicon.svg, so the generic allow-list branch below would + // 404 it. Fetch the SVG bytes and return them with the conservative + // image/x-icon content-type (browsers accept SVG bytes here). Modern + // browsers that already saw the declaration will + // keep using /favicon.svg; this path only fires for legacy clients. + if (url.pathname === "/favicon.ico") { + const icoResponse = await env.ASSETS.fetch( + assetRequest(url, request, "/favicon.svg") + ); + if (icoResponse.ok) { + const headers = new Headers(icoResponse.headers); + headers.set("Content-Type", "image/x-icon"); + // Allow the legacy fallback to be cached separately from the + // canonical SVG. A year is fine — the asset is content-hashed by + // the served URL, not by query string. + headers.set( + "Cache-Control", + "public, max-age=31536000, immutable" + ); + return withSecurityHeaders( + new Response(icoResponse.body, { + status: icoResponse.status, + statusText: icoResponse.statusText, + headers + }) + ); + } + return notFoundResponse("asset_not_found"); + } + if (PUBLIC_ASSET_PATHS.has(url.pathname)) { const ads = googleAdsConversion(env); const isBriefRequestedPage =