From b8dcec13d7f7169f7073c8cd6143ac373dfe73ce Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 16:26:00 +0200 Subject: [PATCH] Publish the apple-app-site-association file The native iPhone app needs this file to use passkeys made on app.ftw.energy. It also lets a pairing code scanned with the camera open the app when it is installed; without the app, /p still opens the web app as before. Apple accepts the file only as JSON. The path has no extension, so _headers sets the type. A test holds the app ID, the /p path and the header together. Co-Authored-By: Claude Opus 5.5 --- .changeset/apple-app-site-association.md | 5 +++ public/.well-known/apple-app-site-association | 15 +++++++ public/_headers | 7 +++ tests/apple-app-site-association.test.ts | 45 +++++++++++++++++++ 4 files changed, 72 insertions(+) create mode 100644 .changeset/apple-app-site-association.md create mode 100644 public/.well-known/apple-app-site-association create mode 100644 tests/apple-app-site-association.test.ts diff --git a/.changeset/apple-app-site-association.md b/.changeset/apple-app-site-association.md new file mode 100644 index 0000000..6516f4d --- /dev/null +++ b/.changeset/apple-app-site-association.md @@ -0,0 +1,5 @@ +--- +"ftw-webapp": patch +--- + +Publish the apple-app-site-association file, so the native iPhone app can use passkeys made on app.ftw.energy and a pairing code scanned with the camera opens the app when it is installed. diff --git a/public/.well-known/apple-app-site-association b/public/.well-known/apple-app-site-association new file mode 100644 index 0000000..802a0b1 --- /dev/null +++ b/public/.well-known/apple-app-site-association @@ -0,0 +1,15 @@ +{ + "webcredentials": { + "apps": ["L66M368PZ6.energy.ftw.app"] + }, + "applinks": { + "details": [ + { + "appIDs": ["L66M368PZ6.energy.ftw.app"], + "components": [ + { "/": "/p", "comment": "A pairing code scanned with the iPhone camera" } + ] + } + ] + } +} diff --git a/public/_headers b/public/_headers index 9e8610d..9bb9736 100644 --- a/public/_headers +++ b/public/_headers @@ -43,3 +43,10 @@ # changes when the bytes do. /assets/* Cache-Control: public, max-age=31536000, immutable + +# Apple reads this file to tie the native iPhone app to this domain. A passkey +# made here then works in the app too, and a pairing code scanned with the +# camera opens the app when it is installed. Apple only accepts it as JSON, +# and the file has no extension, so the type is set here. +/.well-known/apple-app-site-association + Content-Type: application/json diff --git a/tests/apple-app-site-association.test.ts b/tests/apple-app-site-association.test.ts new file mode 100644 index 0000000..6da6a0f --- /dev/null +++ b/tests/apple-app-site-association.test.ts @@ -0,0 +1,45 @@ +/* The file that ties the native iPhone app to app.ftw.energy. + * + * Passkeys are bound to this domain. Without this file the app cannot use + * them, so one passkey would not open the same home on the web and in the + * app. Apple fetches it without a browser, so a typo shows up only as a + * passkey prompt that never appears on a phone. These tests catch it here. + */ + +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' + +const APP_ID = 'L66M368PZ6.energy.ftw.app' +const PATH = '/.well-known/apple-app-site-association' + +type Aasa = { + webcredentials?: { apps?: string[] } + applinks?: { details?: { appIDs?: string[]; components?: { '/'?: string }[] }[] } +} + +function aasa(): Aasa { + return JSON.parse(readFileSync(`public${PATH}`, 'utf8')) as Aasa +} + +describe('the apple-app-site-association file', () => { + it('lets the app use passkeys made on this domain', () => { + expect(aasa().webcredentials?.apps).toEqual([APP_ID]) + }) + + it('opens the app only for the pairing path the web app also serves', () => { + const details = aasa().applinks?.details ?? [] + expect(details.map(d => d.appIDs)).toEqual([[APP_ID]]) + const paths = details.flatMap(d => d.components ?? []).map(c => c['/']) + expect(paths).toEqual(['/p']) + // The web app answers the same path, so a phone without the app still pairs. + const redirects = readFileSync('public/_redirects', 'utf8') + expect(redirects).toMatch(/^\/p \/index\.html 200$/m) + }) + + it('is served as JSON', () => { + const headers = readFileSync('public/_headers', 'utf8') + const block = headers.split(/\n(?=\S)/).find(b => b.startsWith(`${PATH}\n`)) + expect(block, `no _headers rule for ${PATH}`).toBeDefined() + expect(block).toMatch(/^\s+Content-Type: application\/json$/m) + }) +})