Skip to content

feat(favicon): per-app SVG letter-mark fallback in every samohost-generated vhost - #185

Merged
samo-agent merged 6 commits into
mainfrom
feat/favicon-fallback
Jul 20, 2026
Merged

feat(favicon): per-app SVG letter-mark fallback in every samohost-generated vhost#185
samo-agent merged 6 commits into
mainfrom
feat/favicon-fallback

Conversation

@samo-agent

Copy link
Copy Markdown
Collaborator

Summary

  • Adds src/caddy/favicon.ts with a deterministic per-app SVG letter-mark generator (first letter of app name, hue from djb2 hash, no SAMO platform branding colors)
  • Injects a favicon fallback Caddy handle block into both static-app vhosts (staticMainVhostLines) and node-app vhosts (renderVhost) so every samohost-deployed app gets a favicon at first deploy
  • Apps that ship their own /favicon.ico are not overridden: static apps use a Caddy file matcher; node apps use handle_response @fav404 to only serve the letter-mark on upstream 404
  • All 3 callers of staticMainVhostLines (heal-script, script.ts, bootstrap.ts) updated to pass app.name — maintains the three-way byte-identity contract

Root cause

samohost-generated Caddy vhosts had no /favicon.ico route, so all 6 client apps (samograph, field-record, samorev, game-changers, friends-of-twin-peaks, gregg-brandalise) showed the browser's default "no favicon" icon.

Fix layer

No per-repo edits. Fix is at the routing/deploy layer in the two Caddy generators. The existing 3-min auto-heal timer will converge all 5 app VMs within ~6 min after merge.

Constraints met

  • No paid image-gen API (pure SVG, free, deterministic)
  • No SAMO platform branding on client-facing sites (no #4263eb, no #0c7f4d)
  • Overridable: app's own favicon wins via file matcher / upstream 200
  • Back-compat: callers without appName param get no favicon block (optional param)
  • Caddy validate passes for both static and node vhost shapes

Test plan

  • 54 new tests in test/favicon.test.ts — SVG generation, Caddy snippet shape, staticMainVhostLines integration, renderVhost integration, no SAMO branding
  • caddy validate passes locally for both static and node vhost shapes
  • Existing caddy-a1 golden test updated to include favicon block in fixture
  • Byte-identity tests (heal-static-vhost-byte-identity.test.ts) still pass
  • Full suite: 2205 pass, 2 fail (pre-existing auth/bcryptjs packaging issue, not from this PR)
  • Post-merge: curl -I /favicon.ico on each of the 6 hosts expecting 200 + image/svg+xml

Post-merge apply plan

After merge, the samohost-trigger.timer (every 3 min) will detect generatorSha drift for all 6 apps and auto-heal. No manual steps required. Verify after ~6 min:

for host in samograph.samo.team field-record-1.samo.team samorev.samo.team \
            game-changers.samo.team friends-of-twin-peaks.samo.team gregg-brandalise.samo.team; do
  curl -sI "http://$host/favicon.ico" | grep -E "HTTP|content-type"
done

🤖 Generated with Claude Code

samo-agent and others added 4 commits July 20, 2026 18:26
…ntation

Tests cover:
- faviconSvg(appName): letter-mark SVG, deterministic color hash, no SAMO branding (#4263eb/#0c7f4d), no $ or backtick (heredoc-safe), single-quoted attrs
- faviconVhostLinesStatic/Node: Caddy snippet shape, file-first / fallback, image/svg+xml
- staticMainVhostLines: new optional appName param, favicon block before closing brace
- renderVhost: favicon block before catch-all, correct letter, content-type, back-compat
- No SAMO platform indigo on any of the 6 client apps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…erated vhost

Implements git-first favicon provisioning so every client app served by samohost
gets a favicon at deploy time with no per-repo chore. Apps that ship their own
favicon are not overridden.

Root cause fixed: samohost-generated vhosts had no favicon route, causing all
6 client apps (samograph, field-record, samorev, game-changers, friends-of-twin-peaks,
gregg-brandalise) to show the browser's default "no favicon" icon.

Changes:
- src/caddy/favicon.ts (new): faviconSvg(appName) generates a 32x32 SVG letter-mark
  (first letter, hue from djb2 hash, no SAMO platform colors #4263eb/#0c7f4d).
  faviconVhostLinesStatic/Node() return Caddy snippet lines for each generator.
- src/app/heal-script.ts: staticMainVhostLines() gains optional appName param;
  when present inserts favicon fallback handle before closing brace. All 3 callers
  (heal, deploy/script.ts, bootstrap.ts) now pass app.name.
- src/caddy/render.ts: VhostPlan gains optional appName field; renderVhost() inserts
  favicon handle block BEFORE the catch-all when appName is set. planFromApp() sets
  appName=app.name so all node app vhosts get the block automatically.
- test/favicon.test.ts: 54 tests covering SVG generation, Caddy snippet shape,
  staticMainVhostLines integration, renderVhost integration, no SAMO branding.
- test/fixtures/samograph-main-vhost.caddy: updated to include favicon block
  (caddy-a1 golden test pins new expected shape).

Caddy validate: both static and node vhost shapes pass `caddy validate`.
No extra CI infra needed; auto-heal deploys to all 6 apps within 2 timer cycles
(~6 min) after merge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The rebase of the GREEN commit onto the RED commit left a conflict marker
in the favicon block position test. Resolved to use the multi-route plan
form (which correctly creates a catch-all handle to test ordering against).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Caddy's caddyfile adapter reorders directives by built-in precedence —
try_files (which becomes a `rewrite` handler) is always placed BEFORE
`handle` blocks regardless of source order. The `try_files =404` sentinel
causes Caddy to short-circuit with 404 before any later handle can fire,
making the favicon handle block silently dead code.

Fix: wrap the entire static vhost body in a `route {}` directive, which
opts out of the adapter's reordering and preserves exact source order.
Inside the route block, favicon handles appear BEFORE try_files.

Also restructures the static favicon logic to separate /favicon.svg
(always serves generated SVG) from /favicon.ico (serves app's own file
if present, otherwise falls back to generated SVG). This correctly
handles the case where an app ships its own favicon.ico without
accidentally returning 404 for /favicon.svg.

Local proof (caddy validate + curl):
  /favicon.svg  (no file)     → 200 image/svg+xml  letter-mark SVG
  /favicon.ico  (no file)     → 200 image/svg+xml  letter-mark SVG
  /favicon.ico  (file exists) → 200 app's own file (NOT overridden)
  /favicon.svg  (file exists) → 200 image/svg+xml  letter-mark SVG
  /index.html   (exists)      → 200 correct content
  /nonexistent  (absent)      → 404 (normal behavior preserved)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@samo-agent

Copy link
Copy Markdown
Collaborator Author

VERDICT: REQUEST CHANGES

Gate status

Findings

1. BLOCKING — Cache headers silently dropped for all static apps — src/caddy/favicon.ts / src/app/heal-script.ts

faviconVhostBodyLines() returns the entire static vhost body but does NOT include staticCacheHeaderLines(). All 3 production callers of staticMainVhostLines (heal-script.ts:65+74, script.ts:86, bootstrap.ts:9) now pass app.name, which routes them into the new favicon code path. The old path (with cache headers) is only reached when appName is absent — which no production caller does after this PR.

Result: all static apps lose:

  • Cache-Control: public, max-age=31536000, immutable on fingerprinted assets
  • Cache-Control: no-cache on documents / config.js / version.json

The PR comment's justification is incorrect: @samohost_immutable matches [._-][A-Za-z0-9_-]{8,}.(css|js|...)$ and @samohost_documents matches / */ *.html /config.js /version.json — neither pattern matches /favicon.ico. The two matchers are fully compatible with the route {} block and could be placed inside it.

The existing byte-identity tests do not catch this because they call staticMainVhostLines without the appName param, exercising only the old path.

Fix: add ...staticCacheHeaderLines() inside faviconVhostBodyLines() (inside the route {} block, before try_files), and add a test asserting that buildConfigHealScript(staticApp()) output contains max-age=31536000 and Cache-Control "no-cache".


2. INFORMATIONAL — app.name is an unvalidated string; a " in the first char breaks Caddy config — src/caddy/favicon.ts:195+202

letter = (appName[0] ?? "A").toUpperCase() is interpolated directly into the SVG text node: `>${letter}</text>`. The SVG is then embedded in a Caddy respond "${svg}" 200 double-quoted string. If appName[0] were " (double-quote), the Caddy config line would become:

respond "...<text ...>"</text></svg>" 200

This would break Caddy config parsing.

The AppSpec type declares name: string with no regex constraint, and runAppRegister has no name validation (only appUser, topology, and release channel fields are validated). In practice all current app names are [a-z0-9-] and operator-controlled, so this is not a realistic attack vector. But a defensive guard or a test covering a name starting with " would make the assumption explicit. Not blocking.


Remainder — PASS

  • M1 pipeline GREEN (all steps pass including caddy-a1 with SAMOHOST_REQUIRE_CADDY=1)
  • TDD: RED commit 34212693 ("test(favicon): RED") precedes GREEN commit 2fb71d05 ("feat(favicon): GREEN") ✓
  • No SAMO platform branding (#4263eb / #0c7f4d) on any of the 6 client apps — enforced by tests ✓
  • Overridable: static apps use Caddy file matcher; node apps use handle_response @fav404 { } → app's own favicon wins on 200 ✓
  • No secrets or credentials in diff ✓
  • No wall-clock timeouts (no setTimeout / setInterval / sleep / Promise.race) ✓
  • No caps-as-termination (no max_iterations / max_retries) ✓
  • No fake-UX language ✓
  • No user-visible jargon (SVG letter-mark text is a single letter — "S", "F", etc.) ✓
  • Caddy handle_response inside reverse_proxy {} is valid Caddyfile syntax — confirmed by updated golden fixture passing caddy-a1 ✓
  • Three-way deploy==heal==bootstrap byte-identity contract maintained (all 3 callers use the shared helper) ✓
  • Back-compat: callers without appName param retain old behaviour ✓
  • Scope tight: no per-repo edits, fix is entirely at the Caddy generator layer ✓

Suggested next steps

  • Add ...staticCacheHeaderLines() inside faviconVhostBodyLines() (inside the route {} block, before try_files) and a test verifying buildConfigHealScript(staticApp()) still contains the immutable and no-cache headers — then re-request review.
  • (Optional, non-blocking) Add an appName format guard in runAppRegister with a regex like /^[a-z][a-z0-9-]*$/ to make the injection-safety assumption explicit.

🤖 Generated with Claude Code

samo-agent and others added 2 commits July 20, 2026 19:19
Two categories of tests that FAIL on the current implementation:

1. REGRESSION — cache headers lost on favicon-enabled static vhosts:
   faviconVhostBodyLines() omits staticCacheHeaderLines(), so every static
   app using the favicon code path (game-changers, samorev,
   friends-of-twin-peaks, gregg-brandalise) loses Cache-Control:
   public/immutable on fingerprinted assets and Cache-Control: no-cache on
   documents. Tests assert both headers ARE present in staticMainVhostLines
   output (WITH appName) AND in the full buildConfigHealScript output.

2. NAME GUARD — hostile appName not validated:
   app.name flows unvalidated into Caddy respond strings and SVG text.
   Tests assert faviconVhostBodyLines and faviconVhostLinesNode THROW on
   names containing '"', '<', '&' (Caddy/SVG-breaking chars), and DO NOT
   throw on the real production names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ame guard

BLOCKING FIX — cache headers restored on favicon-enabled static vhosts:
  faviconVhostBodyLines() was omitting staticCacheHeaderLines(), so all
  static apps using the favicon code path (game-changers, samorev,
  friends-of-twin-peaks, gregg-brandalise) lost:
    - Cache-Control: public, max-age=31536000, immutable on fingerprinted assets
    - Cache-Control: no-cache on documents (/, *.html, config.js, version.json)

  Fix: emit staticCacheHeaderLines("\t\t") INSIDE the route{} block,
  before try_files. The @samohost_immutable regexp matches only fingerprinted
  filenames (hash suffix) and @samohost_documents path matches only HTML/config
  documents — neither matches /favicon.ico or /favicon.svg, so the favicon
  handles are fully compatible with the cache matchers in the same route{}.

  Also corrects the wrong justifying comment in staticMainVhostLines().

NITS FIX — defensive name guard:
  App names are operator-controlled and currently all [a-z0-9-], so this
  is not a live risk. Added assertSafeAppName() guard to faviconVhostBodyLines
  and faviconVhostLinesNode that throws on names containing '"', '<', '&' or
  any other char outside [a-zA-Z0-9_.-]. Makes the safe-charset assumption
  explicit and machine-checked.

  Also fixes the tlsLine indent (was \t\t, now \t — site-block level).

Caddy validation: both static and node vhost outputs pass `caddy validate`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@samo-agent
samo-agent merged commit 472f8e8 into main Jul 20, 2026
1 check passed
@samo-agent
samo-agent deleted the feat/favicon-fallback branch July 20, 2026 19:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant