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:
- protocol is
https: - hostname is exactly
apps.apple.comoritunes.apple.com
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
-
Type —
Siteinterface (functions/src/types/index.ts:294): addappStoreUrl?: string. Existing unusedappStoreId?is left untouched to avoid disturbing smart-banner code. -
Schema —
siteSchema(api.ts:375): addappStoreUrl: 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. -
Handler — PATCH
/api/apps/:appId/site(api.ts:399): destructureappStoreUrland include it in thesaveSitepayload. No DB-layer change:saveSitealready merges arbitrary fields (functions/src/services/db.service.ts:117-124). -
Dashboard field allowlist —
docs/assets/js/dashboard.js:41-44: add"appStoreUrl"so it round-trips through the local cache. -
Dashboard UI —
docs/dashboard.htmlSite 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). -
Template render vars —
functions/src/services/template.service.ts(~lines 166-220, alongsideiconUrl): passappStoreUrlinto the home and site page render contexts. -
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).appStoreUrlis emitted as an HTML attribute value and is already host-validated; ensure it flows through the same escaping path asiconUrl. -
Tests —
- Unit: URL validator accepts
https://apps.apple.com/...andhttps://itunes.apple.com/...; rejectshttp://, other hosts,javascript:, and non-URL strings. - Unit/render: home + site templates emit the anchor when
appStoreUrlis set and the bare<img>when it is not.
- Unit: URL validator accepts
Out of Scope
- No numeric-ID migration or coupling to
smartBanner.appStoreId. - No auto-fetch of App Store metadata.
- No changes to the wizard flow (identity lives in the dashboard, not the wizard).
Estimate
~2–3 hours including tests. Well-bounded, no architectural risk.