diff --git a/compute-js/src/index.js b/compute-js/src/index.js index 032f4e63..b0fa2bf0 100644 --- a/compute-js/src/index.js +++ b/compute-js/src/index.js @@ -66,18 +66,7 @@ async function handleRequest(event) { } // Other .well-known files (apple-app-site-association, assetlinks.json) console.log('Handling subdomain .well-known file:', url.pathname); - const wkResponse = await publisherServer.serveRequest(request); - // Guard: if publisher returns text/html, it's the SPA fallback, not the real file - if (wkResponse != null && wkResponse.status === 200 && !wkResponse.headers.get('Content-Type')?.includes('text/html')) { - const headers = new Headers(wkResponse.headers); - const contentType = url.pathname.endsWith('.json') || url.pathname.endsWith('/apple-app-site-association') - ? 'application/json' - : headers.get('Content-Type') || 'application/octet-stream'; - headers.set('Content-Type', contentType); - headers.set('Cache-Control', 'public, max-age=3600'); - return new Response(wkResponse.body, { status: 200, headers }); - } - return new Response('Not Found', { status: 404 }); + return await serveWellKnownFile(request, url.pathname); } // Subdomain profile - serve SPA with injected user data @@ -127,24 +116,16 @@ async function handleRequest(event) { // apple-app-site-association has no file extension, so the static publisher // cannot detect its content type - we handle it explicitly here. console.log('Handling .well-known file:', url.pathname); - const wkResponse = await publisherServer.serveRequest(request); - // Guard: if publisher returns text/html, it's the SPA fallback, not the real file - if (wkResponse != null && wkResponse.status === 200 && !wkResponse.headers.get('Content-Type')?.includes('text/html')) { + const wkResponse = await serveWellKnownFile(request, url.pathname); + if (wkResponse.status === 200) { const headers = new Headers(wkResponse.headers); - // Ensure correct content type for app association files - const contentType = url.pathname.endsWith('.json') || url.pathname.endsWith('/apple-app-site-association') - ? 'application/json' - : headers.get('Content-Type') || 'application/octet-stream'; - headers.set('Content-Type', contentType); - headers.set('Cache-Control', 'public, max-age=3600'); headers.append('Vary', 'X-Original-Host'); return new Response(wkResponse.body, { status: 200, headers, }); } - // File not found in KV - return 404 instead of SPA fallback - return new Response('Not Found', { status: 404 }); + return wkResponse; } // 5. Handle dynamic OG meta tags for crawler requests @@ -1320,3 +1301,40 @@ function humanizeCategoryName(name) { .map(part => part.charAt(0).toUpperCase() + part.slice(1)) .join(' '); } + +/** + * Serve .well-known association files from static publish output. + * Falls back to root filenames when needed to tolerate publisher path quirks. + */ +async function serveWellKnownFile(request, pathname) { + const candidatePaths = [pathname]; + + if (pathname === '/.well-known/assetlinks.json') { + candidatePaths.push('/assetlinks.json'); + } else if (pathname === '/.well-known/apple-app-site-association') { + candidatePaths.push('/apple-app-site-association'); + } + + for (const candidatePath of candidatePaths) { + const candidateUrl = new URL(request.url); + candidateUrl.pathname = candidatePath; + candidateUrl.search = ''; + + const candidateRequest = new Request(candidateUrl.toString(), request); + const wkResponse = await publisherServer.serveRequest(candidateRequest); + const contentType = wkResponse?.headers.get('Content-Type') || ''; + + // Static publish returns HTML when falling back to SPA; ignore those responses. + if (wkResponse != null && wkResponse.status === 200 && !contentType.includes('text/html')) { + const headers = new Headers(wkResponse.headers); + if (pathname.endsWith('.json') || pathname.endsWith('/apple-app-site-association')) { + headers.set('Content-Type', 'application/json'); + } + headers.set('Cache-Control', 'public, max-age=3600'); + return new Response(wkResponse.body, { status: 200, headers }); + } + } + + // File not found in KV - return 404 instead of SPA fallback. + return new Response('Not Found', { status: 404 }); +} diff --git a/package.json b/package.json index 3c6bee05..a057dbbb 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "fastly:local": "npm run build && cd compute-js && npm run dev:publish && npm run dev:start", "fastly:deploy": "npm run build && cd compute-js && npm run fastly:deploy", "fastly:publish": "npm run build && cd compute-js && npm run fastly:publish", + "fastly:release": "npm run fastly:deploy && npm run fastly:publish", + "verify:well-known": "node scripts/verify-well-known.mjs", "precalculate-thumbnails": "tsx scripts/precalculate-hashtag-thumbnails.ts", "generate-icons": "node scripts/generate-icons.js", "test:visual": "playwright test", diff --git a/public/.well-known/apple-app-site-association b/public/.well-known/apple-app-site-association index 0583aa22..c7fae0ed 100644 --- a/public/.well-known/apple-app-site-association +++ b/public/.well-known/apple-app-site-association @@ -21,6 +21,10 @@ "/": "/search/*", "comment": "Search deep links" }, + { + "/": "/invite/*", + "comment": "Invite redemption deep links" + }, { "/": "/app/callback", "comment": "OAuth callback for Divine app" diff --git a/scripts/verify-well-known.mjs b/scripts/verify-well-known.mjs new file mode 100644 index 00000000..02d6d53e --- /dev/null +++ b/scripts/verify-well-known.mjs @@ -0,0 +1,67 @@ +// ABOUTME: Verifies deep-link association files are reachable and valid JSON. +// ABOUTME: Smoke check for .well-known apple-app-site-association and assetlinks.json. + +const baseUrl = process.argv[2] || 'https://divine.video'; + +async function fetchJson(pathname) { + const url = new URL(pathname, baseUrl); + const response = await fetch(url); + const body = await response.text(); + + if (!response.ok) { + throw new Error(`${url.toString()} returned ${response.status}: ${body.slice(0, 200)}`); + } + + const contentType = response.headers.get('content-type') || ''; + if (!contentType.toLowerCase().includes('application/json')) { + throw new Error(`${url.toString()} returned unexpected Content-Type: ${contentType}`); + } + + let json; + try { + json = JSON.parse(body); + } catch (error) { + throw new Error(`${url.toString()} returned invalid JSON: ${error.message}`); + } + + return { url: url.toString(), json }; +} + +function assertInviteRoute(aasa) { + const components = aasa?.applinks?.details?.[0]?.components; + if (!Array.isArray(components)) { + throw new Error('AASA missing applinks.details[0].components array'); + } + + const hasInvite = components.some((component) => component?.['/'] === '/invite/*'); + if (!hasInvite) { + throw new Error('AASA does not include /invite/* component'); + } +} + +function assertAssetLinks(assetLinks) { + if (!Array.isArray(assetLinks) || assetLinks.length === 0) { + throw new Error('assetlinks.json must be a non-empty array'); + } + + const hasOpenvineTarget = assetLinks.some((entry) => entry?.target?.package_name === 'co.openvine.app'); + if (!hasOpenvineTarget) { + throw new Error('assetlinks.json does not include co.openvine.app target'); + } +} + +async function main() { + const aasa = await fetchJson('/.well-known/apple-app-site-association'); + assertInviteRoute(aasa.json); + + const assetLinks = await fetchJson('/.well-known/assetlinks.json'); + assertAssetLinks(assetLinks.json); + + console.log(`OK ${aasa.url}`); + console.log(`OK ${assetLinks.url}`); +} + +main().catch((error) => { + console.error(error.message); + process.exit(1); +});