App Store Link on Site Icon — Design

Date: 2026-05-19 Status: Approved (approach), pending spec review

Problem

Customers want their hosted privacy/site pages to feel like a real product page: when a visitor taps the app icon, it should open the app’s App Store listing. The link must be constrained to App Store URLs only — these are public, user-controlled pages, so an unvalidated href is a phishing / open-redirect surface.

Decision

Store a full App Store URL (not a numeric ID) on the site record. Chosen because it matches what customers already have on hand (the share link from the App Store), is display-ready with no reconstruction, and is just as safe once the URL is validated against a host allowlist server-side.

Rejected alternative: store only the numeric App Store ID (like smartBanner.appStoreId). More tamper-proof but forces customers to dig the ID out of App Store Connect — unnecessary friction for the same end result.

Scope

Only the large hero icon on the home page (home.html:245) becomes tappable. The nav-bar brand icon is already wrapped in <a href="./"> (logo → home, the standard web convention); nesting a second anchor inside it would be invalid HTML and would hijack expected logo behavior. So the hero icon is the single, correct target — it matches the user’s literal request (“tap on the icon”). Link opens with target="_blank" rel="noopener".

Security Boundary

Enforcement lives in the server-side Zod siteSchema (functions/src/handlers/api.ts:375), consistent with every other validated input. A .refine() parses the candidate URL and accepts it only if all of:

Anything else (http, other hosts, non-URL strings) is rejected with a clear message. Client-side validation, if added, is UX sugar only — the API is the boundary. Empty string / omitted clears the link (treated as “no link”).

Changes

  1. TypeSite interface (functions/src/types/index.ts:294): add appStoreUrl?: string. Existing unused appStoreId? is left untouched to avoid disturbing smart-banner code.

  2. SchemasiteSchema (api.ts:375): add appStoreUrl: z.string().trim().max(2048).refine(<https + host allowlist>).optional(), plus allow empty string to clear. Exact shape decided at implementation; tight schema, no .catchall.

  3. Handler — PATCH /api/apps/:appId/site (api.ts:399): destructure appStoreUrl and include it in the saveSite payload. No DB-layer change: saveSite already merges arbitrary fields (functions/src/services/db.service.ts:117-124).

  4. Dashboard field allowlistdocs/assets/js/dashboard.js:41-44: add "appStoreUrl" so it round-trips through the local cache.

  5. Dashboard UIdocs/dashboard.html Site Identity card (after the Description textarea, ~line 431): one URL input with a hint pointing to the App Store share link. Load/save wired in the identity save block (dashboard.js:~1728).

  6. Template render varsfunctions/src/services/template.service.ts (~lines 166-220, alongside iconUrl): pass appStoreUrl into the home and site page render contexts.

  7. Template — wrap only the hero icon (functions/src/templates/home.html:245) in <a href="" target="_blank" rel="noopener">…</a>, falling back to the bare <img> when unset. Nav brand icons are left as-is (already anchors to home). appStoreUrl is emitted as an HTML attribute value and is already host-validated; ensure it flows through the same escaping path as iconUrl.

  8. Tests

    • Unit: URL validator accepts https://apps.apple.com/... and https://itunes.apple.com/...; rejects http://, other hosts, javascript:, and non-URL strings.
    • Unit/render: home + site templates emit the anchor when appStoreUrl is set and the bare <img> when it is not.

Out of Scope

Estimate

~2–3 hours including tests. Well-bounded, no architectural risk.