// Web presence for an app: universal links (apple-app-site-association), Android App Links (Digital Asset Links // assetlinks.json), the Smart App Banner tag and a minimal landing page. Pure: builders and validators, no IO. // Sources (checked October 2026): // https://developer.apple.com/documentation/xcode/supporting-associated-domains (AASA format: applinks.details[] // .appIDs + components with "/", "?", "#", "exclude", "comment"; webcredentials.apps; served at // https:///.well-known/apple-app-site-association, no extension, HTTPS with a valid certificate, no // redirects; since iOS 14 / macOS 11 Apple's CDN fetches it, devices recheck about once a week) // https://developer.apple.com/documentation/technotes/tn3155-debugging-universal-links (no 301/302, reachable from // every IP, every user agent; legacy appID + paths format still read, do not mix the two) // Content type application/json and the 128 KB limit (uncompressed) come from Apple's archived "Support Universal // Links" guide; the current pages do not restate them, so check treats a non-JSON type and > 128 KB as problems. // https://developer.apple.com/documentation/webkit/promoting-apps-with-smart-app-banners // (, in the head of each page) // https://developer.android.com/training/app-links/configure-assetlinks (statement list: relation // ["delegate_permission/common.handle_all_urls"], target { namespace "android_app", package_name, // sha256_cert_fingerprints ["AB:CD:..."] }; https:///.well-known/assetlinks.json, content-type // application/json, HTTPS, no redirects; relation_extensions.dynamic_app_link_components on Android 15+) // https://developer.android.com/training/app-links/verify-android-applinks (autoVerify, per-host lookup) import type { ShipConfig } from './config'; export const AASA_PATH = '/.well-known/apple-app-site-association'; export const AASA_LEGACY_PATH = '/apple-app-site-association'; export const ASSETLINKS_PATH = '/.well-known/assetlinks.json'; export const HANDLE_ALL_URLS = 'delegate_permission/common.handle_all_urls'; export const AASA_MAX_BYTES = 128 * 1024; export type Level = 'ok' | 'problem' | 'info'; export interface Finding { level: Level; message: string; fix?: string } type Cfg = Pick; const appIdOf = (c: Cfg): string | null => (c.apple?.teamId && c.apple.bundleId ? `${c.apple.teamId}.${c.apple.bundleId}` : null); const pathsOf = (c: Cfg): string[] => (c.web?.paths?.length ? c.web.paths : ['/*']); /** apple-app-site-association for the config, null without an apple section with a teamId. Excluded paths come * first: the system stops at the first component that matches. */ export function aasaFor(c: Cfg): Record | null { const id = appIdOf(c); if (!id) return null; const components = [ ...(c.web?.exclude ?? []).map(p => ({ '/': p, exclude: true, comment: 'stays in the browser' })), ...pathsOf(c).map(p => ({ '/': p })), ]; return { applinks: { details: [{ appIDs: [id], components }] }, ...(c.web?.webcredentials ? { webcredentials: { apps: [id] } } : {}), }; } /** assetlinks.json for the config, null without a play section or without a fingerprint. With web.exclude it adds * dynamic rules (Android 15+; older versions ignore them and use the intent filters only). */ export function assetLinksFor(c: Cfg, fingerprints = c.web?.androidSha256 ?? []): Record[] | null { if (!c.play?.packageName || !fingerprints.length) return null; const st: Record = { relation: [HANDLE_ALL_URLS], target: { namespace: 'android_app', package_name: c.play.packageName, sha256_cert_fingerprints: fingerprints }, }; if (c.web?.exclude?.length) st.relation_extensions = { [HANDLE_ALL_URLS]: { dynamic_app_link_components: [...c.web.exclude.map(p => ({ '/': p, exclude: true })), ...pathsOf(c).map(p => ({ '/': p }))] }, }; return [st]; } const esc = (s: string): string => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); /** The Smart App Banner meta tag, null without apple.appId. */ export function smartBannerTag(c: Cfg, appArgument?: string): string | null { if (!c.apple?.appId) return null; return ``; } export const appStoreUrl = (appId: string): string => `https://apps.apple.com/app/id${appId}`; export const playUrl = (pkg: string): string => `https://play.google.com/store/apps/details?id=${pkg}`; /** templates/web/landing.html with the config filled in. Blocks between and (apple, play, terms) * are dropped when the config has nothing for them. */ export function fillLanding(template: string, c: Cfg): string { const keep: Record = { apple: !!c.apple?.appId, play: !!c.play?.packageName, terms: !!c.app.termsUrl }; let s = template.replace(/([\s\S]*?)/g, (_, k: string, body: string) => (keep[k] ? body : '')); s = s.replace(/^[ \t]*\n/gm, ''); const tag = smartBannerTag(c); s = s.replace(/^([ \t]*)\n/m, (_, ind: string) => (tag ? `${ind}${tag}\n` : '')); const vals: Record = { APP_NAME: c.app.name, APP_STORE_URL: c.apple?.appId ? appStoreUrl(c.apple.appId) : '', PLAY_URL: c.play?.packageName ? playUrl(c.play.packageName) : '', PRIVACY_URL: c.app.privacyPolicyUrl, SUPPORT_URL: c.app.supportUrl, TERMS_URL: c.app.termsUrl ?? '', }; return s.replace(/\{\{([A-Z_]+)\}\}/g, (m, k: string) => (k in vals ? esc(vals[k]!) : m)); } /** Any SHA-256 form (AB:CD:.. , plain hex in any case, base64 / base64url of the 32 bytes) -> "AB:CD:..." or null. * The Play Developer API documents certificateSha256Hash only as "SHA256 hash of the APK signing public key * certificate" (type string, encoding not stated), so both hex and base64 are accepted. */ export function normalizeFingerprint(s: string): string | null { const t = s.trim(); const plain = t.replace(/[:\s]/g, ''); let hex: string | undefined; if (/^[0-9a-fA-F]{64}$/.test(plain)) hex = plain; else if (/^[A-Za-z0-9+/_-]{43}=?$/.test(t)) { const b = Buffer.from(t.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); if (b.length === 32) hex = b.toString('hex'); } return hex ? hex.toUpperCase().match(/../g)!.join(':') : null; } const isObj = (x: unknown): x is Record => !!x && typeof x === 'object' && !Array.isArray(x); const strs = (x: unknown): x is string[] => Array.isArray(x) && x.every(v => typeof v === 'string'); const COMPONENT_KEYS = new Set(['/', '?', '#', 'exclude', 'comment', 'caseSensitive', 'percentEncoded']); const REGEN = 'regenerate with `storebound web init` and deploy the file again'; /** Shape and ids of an apple-app-site-association document. ids missing = shape only. */ export function validateAasa(json: unknown, ids: { teamId?: string; bundleId?: string; webcredentials?: boolean }): Finding[] { const f: Finding[] = []; const bad = (message: string, fix = REGEN) => f.push({ level: 'problem', message, fix }); if (!isObj(json)) { bad('not a JSON object'); return f; } const al = json.applinks; if (!isObj(al)) { bad('no "applinks" object'); return f; } if (!Array.isArray(al.details) || !al.details.length) { bad('"applinks.details" is not a non-empty list'); return f; } const found: string[] = []; for (const [i, d] of al.details.entries()) { const at = `applinks.details[${i}]`; if (!isObj(d)) { bad(`${at} is not an object`); continue; } const modern = 'appIDs' in d || 'components' in d, legacy = 'appID' in d || 'paths' in d; if (modern && legacy) bad(`${at} mixes appIDs/components with the legacy appID/paths keys`); if (modern) { if (!strs(d.appIDs) || !d.appIDs.length) bad(`${at}.appIDs is not a non-empty list of strings`); else found.push(...d.appIDs); if (!Array.isArray(d.components) || !d.components.length) bad(`${at}.components is not a non-empty list: no URL opens the app`); else { for (const [j, c] of d.components.entries()) { const cat = `${at}.components[${j}]`; if (!isObj(c)) { bad(`${cat} is not an object`); continue; } if ('/' in c && typeof c['/'] !== 'string') bad(`${cat}["/"] is not a string`); if ('#' in c && typeof c['#'] !== 'string') bad(`${cat}["#"] is not a string`); if ('?' in c && !(typeof c['?'] === 'string' || (isObj(c['?']) && Object.values(c['?']).every(v => typeof v === 'string')))) bad(`${cat}["?"] is not an object of strings`); if ('exclude' in c && typeof c.exclude !== 'boolean') bad(`${cat}.exclude is not true / false`); const odd = Object.keys(c).filter(k => !COMPONENT_KEYS.has(k)); if (odd.length) f.push({ level: 'info', message: `${cat}: unknown keys ${odd.join(', ')} (ignored by the system)` }); } if (d.components.every(c => isObj(c) && c.exclude === true)) bad(`${at}: every component is excluded, no URL opens the app`); } } else if (legacy) { if (typeof d.appID !== 'string') bad(`${at}.appID is not a string`); else found.push(d.appID); if (!strs(d.paths)) bad(`${at}.paths is not a list of strings`); f.push({ level: 'info', message: `${at} uses the legacy appID / paths format (iOS 12 and earlier); iOS 13+ reads appIDs / components`, fix: REGEN }); } else bad(`${at} has neither appIDs nor appID`); } for (const id of found) if (!/^[A-Z0-9]{10}\.[A-Za-z0-9.*-]+$/.test(id)) bad(`app id "${id}" is not .`); const want = ids.teamId && ids.bundleId ? `${ids.teamId}.${ids.bundleId}` : ''; if (want) { if (found.includes(want)) f.push({ level: 'ok', message: `applinks lists ${want}` }); else bad(`applinks does not list ${want} (found: ${found.join(', ') || 'none'})`, 'check apple.teamId (Membership details) and apple.bundleId, then ' + REGEN); } else f.push({ level: 'info', message: 'apple.teamId or apple.bundleId not in the config: app ids not compared', fix: 'set apple.teamId (developer.apple.com > Account > Membership details)' }); if ('webcredentials' in json) { const wc = json.webcredentials; if (!isObj(wc) || !strs(wc.apps)) bad('"webcredentials.apps" is not a list of strings'); else if (want && ids.webcredentials && !wc.apps.includes(want)) bad(`webcredentials.apps does not list ${want}`); } else if (want && ids.webcredentials) bad('web.webcredentials is on but the file has no "webcredentials" section'); return f; } /** Shape and ids of an assetlinks.json statement list. */ export function validateAssetLinks(json: unknown, ids: { packageName?: string; fingerprints?: string[] }): Finding[] { const f: Finding[] = []; const bad = (message: string, fix = REGEN) => f.push({ level: 'problem', message, fix }); if (!Array.isArray(json)) { bad(isObj(json) ? 'a JSON object, not a list of statements ([{ relation, target }])' : 'not a list of statements'); return f; } if (!json.length) { bad('empty statement list'); return f; } const mine: Record[] = []; for (const [i, s] of json.entries()) { const at = `statement ${i}`; if (!isObj(s) || !strs(s.relation) || !isObj(s.target)) { bad(`${at}: needs "relation" (list of strings) and "target" (object)`); continue; } const t = s.target; if (t.namespace === 'android_app') { if (typeof t.package_name !== 'string') bad(`${at}: target.package_name missing`); if (!strs(t.sha256_cert_fingerprints) || !t.sha256_cert_fingerprints.length) bad(`${at}: target.sha256_cert_fingerprints is not a non-empty list`); else for (const fp of t.sha256_cert_fingerprints) if (!/^([0-9A-F]{2}:){31}[0-9A-F]{2}$/.test(fp)) bad(`${at}: fingerprint "${fp.slice(0, 20)}..." is not in the AB:CD:... form (32 upper-case hex pairs)`); if (!ids.packageName || t.package_name === ids.packageName) mine.push(s); } else if (t.namespace !== 'web') bad(`${at}: target.namespace "${String(t.namespace)}" is not android_app or web`); } if (!ids.packageName) { f.push({ level: 'info', message: 'play.packageName not in the config: package not compared' }); return f; } if (!mine.length) { bad(`no android_app statement for ${ids.packageName}`); return f; } const linked = mine.filter(s => (s.relation as string[]).includes(HANDLE_ALL_URLS)); if (!linked.length) { bad(`the statement for ${ids.packageName} has relation ${JSON.stringify(mine[0]!.relation)}, not ${HANDLE_ALL_URLS}`); return f; } f.push({ level: 'ok', message: `${ids.packageName} has ${HANDLE_ALL_URLS}` }); const live = new Set(linked.flatMap(s => ((s.target as Record).sha256_cert_fingerprints as string[] | undefined) ?? []).map(x => normalizeFingerprint(x) ?? x)); const want = (ids.fingerprints ?? []).map(x => normalizeFingerprint(x) ?? x); if (!want.length) { f.push({ level: 'info', message: 'web.androidSha256 not in the config: fingerprints not compared', fix: 'copy the SHA-256 from Play Console > App integrity > Play app signing' }); return f; } const missing = want.filter(x => !live.has(x)), extra = [...live].filter(x => !want.includes(x)); for (const m of missing) bad(`fingerprint ${m.slice(0, 11)}... from web.androidSha256 is missing: app links do not verify for builds signed with it`); if (!missing.length) f.push({ level: 'ok', message: `all ${want.length} fingerprint(s) from web.androidSha256 are listed` }); for (const x of extra) f.push({ level: 'info', message: `extra fingerprint ${x.slice(0, 11)}... (not in web.androidSha256; fine for a debug or upload key)` }); return f; } /** What a fetch of one of the files returned (the command fills it in; tests build it by hand). */ export interface Fetched { url: string; status: number; location?: string; contentType: string; body: string; bytes: number } export interface Check { id: string; url: string; ok: boolean; level: Level; message: string; fix?: string } const check = (id: string, url: string, x: Finding): Check => ({ id, url, ok: x.level !== 'problem', level: x.level, message: x.message, ...(x.fix ? { fix: x.fix } : {}) }); /** Transport checks shared by both files: status, redirects, content type, size, JSON. Returns the parsed JSON (or * undefined when it could not be read). */ function transport(id: string, r: Fetched, o: { anyJsonType: boolean; maxBytes?: number; host: string }): { checks: Check[]; json?: unknown } { const out: Check[] = []; const add = (level: Level, message: string, fix?: string) => out.push(check(id, r.url, { level, message, fix })); if (r.status >= 300 && r.status < 400) { add('problem', `HTTP ${r.status} redirect to ${r.location ?? '(no Location)'}`, `serve the file directly at ${r.url} with status 200 (no http->https, apex->www or trailing-slash redirect for this path)`); return { checks: out }; } if (r.status !== 200) { add('problem', `HTTP ${r.status}`, `upload the file from your web init output folder to ${o.host}${new URL(r.url).pathname}; it must be public (no login, no IP or bot blocking)`); return { checks: out }; } add('ok', 'HTTP 200, no redirect'); const ct = r.contentType.toLowerCase(); const jsonType = o.anyJsonType ? /^application\/([\w.+-]*\+)?json\b/.test(ct) : /^application\/json\b/.test(ct); if (jsonType) add('ok', `content-type ${r.contentType}`); else add('problem', `content-type is "${r.contentType || 'missing'}", not application/json`, 'set Content-Type: application/json for this path on the web server or host (e.g. a headers rule)'); if (o.maxBytes !== undefined) { if (r.bytes > o.maxBytes) add('problem', `${r.bytes} bytes, over the ${o.maxBytes / 1024} KB limit`, 'shorten the file (fewer components, no comments)'); else add('ok', `${r.bytes} bytes (limit ${o.maxBytes / 1024} KB)`); } if (/^\s* ({ teamId: c.apple?.teamId, bundleId: c.apple?.bundleId, webcredentials: c.web?.webcredentials, appId: c.apple?.appId, packageName: c.play?.packageName, fingerprints: c.web?.androidSha256 ?? [], }); /** Every check of the AASA response. */ export function checkAasa(r: Fetched, ids: WebIds, host: string): Check[] { const t = transport('aasa', r, { anyJsonType: true, maxBytes: AASA_MAX_BYTES, host }); if (t.json === undefined) return t.checks; return [...t.checks, ...validateAasa(t.json, ids).map(x => check('aasa', r.url, x))]; } /** The legacy root path: informational only (Apple reads /.well-known first). */ export function checkAasaLegacy(r: Fetched): Check[] { const msg = r.status === 200 ? 'also served at the legacy root path (fine; /.well-known is the one that counts)' : r.status >= 300 && r.status < 400 ? `legacy root path redirects (HTTP ${r.status}); fine as long as /.well-known serves the file` : `legacy root path not served (HTTP ${r.status}); fine, /.well-known is the one that counts`; return [check('aasa-legacy', r.url, { level: 'info', message: msg })]; } export function checkAssetLinks(r: Fetched, ids: WebIds, host: string): Check[] { const t = transport('assetlinks', r, { anyJsonType: false, host }); if (t.json === undefined) return t.checks; return [...t.checks, ...validateAssetLinks(t.json, ids).map(x => check('assetlinks', r.url, x))]; } /** The landing page carries the Smart App Banner tag with the config's app-id. */ export function checkLanding(r: Fetched, ids: WebIds): Check[] { if (r.status !== 200) return [check('landing', r.url, { level: 'problem', message: `HTTP ${r.status}`, fix: 'deploy index.html from the web init output folder' })]; const m = /]*name=["']apple-itunes-app["'][^>]*>/i.exec(r.body) ?? /]*content=["'][^"']*app-id=[^>]*name=["']apple-itunes-app["'][^>]*>/i.exec(r.body); if (!m) return [check('landing', r.url, { level: ids.appId ? 'problem' : 'info', message: 'no apple-itunes-app meta tag (Smart App Banner)', fix: 'add the tag from smart-app-banner.html to the ' })]; const id = /app-id=(\d+)/.exec(m[0])?.[1]; if (ids.appId && id !== ids.appId) return [check('landing', r.url, { level: 'problem', message: `Smart App Banner app-id is ${id ?? 'missing'}, the config says ${ids.appId}`, fix: 'regenerate with `storebound web init` and redeploy index.html' })]; return [check('landing', r.url, { level: 'ok', message: `Smart App Banner app-id=${id}` })]; } export interface PlannedFile { path: string; content: string } /** Files web init writes (paths relative to the output folder) and what it skipped, with why and how to fix. */ export function planWeb(c: Cfg, o: { landing: boolean; template?: string; marks?: Record; fingerprints?: string[] }): { files: PlannedFile[]; skipped: string[] } { const files: PlannedFile[] = [], skipped: string[] = []; const aasa = aasaFor(c); if (aasa) files.push({ path: '.well-known/apple-app-site-association', content: JSON.stringify(aasa, null, 2) + '\n' }); else if (c.apple) skipped.push('.well-known/apple-app-site-association: apple.teamId is not set (developer.apple.com > Account > Membership details > Team ID)'); const fps = o.fingerprints ?? c.web?.androidSha256 ?? []; const links = assetLinksFor(c, fps); if (links) files.push({ path: '.well-known/assetlinks.json', content: JSON.stringify(links, null, 2) + '\n' }); else if (c.play) skipped.push('.well-known/assetlinks.json: no app signing fingerprint: copy the SHA-256 from Play Console > Test and release > App integrity > Play app signing into web.androidSha256, or run `storebound web init --version-code ` to read it from the Play Developer API'); const tag = smartBannerTag(c); if (tag) files.push({ path: 'smart-app-banner.html', content: `\n${tag}\n` }); else if (c.apple) skipped.push('smart-app-banner.html: apple.appId is not set (App Store Connect > App Information > Apple ID)'); if (o.landing) { if (!o.template) skipped.push('index.html: landing template not found'); else { files.push({ path: 'index.html', content: fillLanding(o.template, c) }); if (c.apple?.appId && o.marks?.appstore) files.push({ path: 'appstore.svg', content: o.marks.appstore }); if (c.play?.packageName && o.marks?.googleplay) files.push({ path: 'googleplay.svg', content: o.marks.googleplay }); if (!c.apple?.appId && c.apple) skipped.push('index.html: App Store link left out (apple.appId is not set)'); } } return { files, skipped }; }