storebound/cli/lib/web.ts

295 lines
21 KiB
TypeScript

// 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://<domain>/.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
// (<meta name="apple-itunes-app" content="app-id=..., app-argument=...">, 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://<domain>/.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 { consoleLink, consoleUrl } from './console-links';
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; url?: string }
type Cfg = Pick<ShipConfig, 'app' | 'apple' | 'play' | 'web'>;
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<string, unknown> | 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<string, unknown>[] | null {
if (!c.play?.packageName || !fingerprints.length) return null;
const st: Record<string, unknown> = {
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
/** 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 `<meta name="apple-itunes-app" content="app-id=${esc(c.apple.appId)}${appArgument ? `, app-argument=${esc(appArgument)}` : ''}">`;
}
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 <!--x--> and <!--/x--> (apple, play, terms)
* are dropped when the config has nothing for them. */
export function fillLanding(template: string, c: Cfg): string {
const keep: Record<string, boolean> = { apple: !!c.apple?.appId, play: !!c.play?.packageName, terms: !!c.app.termsUrl };
let s = template.replace(/<!--(\w+)-->([\s\S]*?)<!--\/\1-->/g, (_, k: string, body: string) => (keep[k] ? body : ''));
s = s.replace(/^[ \t]*\n/gm, '');
const tag = smartBannerTag(c);
s = s.replace(/^([ \t]*)<!--\{\{SMART_BANNER\}\}-->\n/m, (_, ind: string) => (tag ? `${ind}${tag}\n` : ''));
const vals: Record<string, string> = {
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<string, unknown> => !!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 <Team ID>.<bundle id>`);
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<string, unknown>[] = [];
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<string, unknown>).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 ${consoleLink('play.app-signing')}`, url: consoleUrl('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*</.test(r.body)) { add('problem', 'the body is an HTML page, not JSON (a single-page-app fallback or a custom 404 page?)', 'make the host serve the real file for this path before any rewrite rules'); return { checks: out }; }
try { return { checks: out, json: JSON.parse(r.body) }; } catch (e) {
add('problem', `not valid JSON: ${(e as Error).message.slice(0, 120)}`, REGEN);
return { checks: out };
}
}
export interface WebIds { teamId?: string; bundleId?: string; webcredentials?: boolean; packageName?: string; fingerprints?: string[]; appId?: string }
export const webIdsOf = (c: Cfg): WebIds => ({
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 = /<meta\s+[^>]*name=["']apple-itunes-app["'][^>]*>/i.exec(r.body) ?? /<meta\s+[^>]*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 <head>' })];
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<string, string>; 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 ' + consoleLink('play.app-signing') + ' into web.androidSha256, or run `storebound web init --version-code <uploaded versionCode>` to read it from the Play Developer API');
const tag = smartBannerTag(c);
if (tag) files.push({ path: 'smart-app-banner.html', content: `<!-- Smart App Banner: paste this tag into the <head> of every page that should show it (Safari on iPhone and iPad). -->\n${tag}\n` });
else if (c.apple) skipped.push(`smart-app-banner.html: apple.appId is not set (${consoleLink('asc.app-info', '> 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 };
}