From 5aae1c5a93b4a6ad3c938111224136af492ff736 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 08:35:38 +0200 Subject: [PATCH 1/4] AASA: open the iPhone app for an assistant's consent link at /a FTW Cloud's consent page links the owner to https://app.ftw.energy/a#.. The app approves it with Face ID. Anywhere without the app, a small static page says to open it on the iPhone with FTW; it reads nothing and runs no script. The rest of the web app is untouched. Co-Authored-By: Claude Opus 5.5 --- .changeset/access-link.md | 5 +++ public/.well-known/apple-app-site-association | 4 ++- public/_redirects | 8 +++++ public/access-link.html | 35 +++++++++++++++++++ tests/apple-app-site-association.test.ts | 14 ++++++-- 5 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 .changeset/access-link.md create mode 100644 public/access-link.html diff --git a/.changeset/access-link.md b/.changeset/access-link.md new file mode 100644 index 0000000..5b8ba81 --- /dev/null +++ b/.changeset/access-link.md @@ -0,0 +1,5 @@ +--- +"ftw-webapp": patch +--- + +An assistant's consent link at app.ftw.energy/a opens the FTW iPhone app, and a small page says to open it there when the app is not installed. diff --git a/public/.well-known/apple-app-site-association b/public/.well-known/apple-app-site-association index 802a0b1..6366448 100644 --- a/public/.well-known/apple-app-site-association +++ b/public/.well-known/apple-app-site-association @@ -7,7 +7,9 @@ { "appIDs": ["L66M368PZ6.energy.ftw.app"], "components": [ - { "/": "/p", "comment": "A pairing code scanned with the iPhone camera" } + { "/": "/p", "comment": "A pairing code scanned with the iPhone camera" }, + { "/": "/a", "comment": "An assistant's consent page asks the owner to approve in the app" }, + { "/": "/a/*", "comment": "The same, with the request in the path" } ] } ] diff --git a/public/_redirects b/public/_redirects index 420b1b8..93df431 100644 --- a/public/_redirects +++ b/public/_redirects @@ -9,5 +9,13 @@ # scanned in the address bar, which is what they can check against the box. /p /index.html 200 +# /a is where an assistant's consent page at FTW Cloud sends the owner, with +# the signed offer in the fragment. The iPhone app opens it when installed +# (apple-app-site-association). Anywhere else this small page says to open +# it on the iPhone with FTW; it never reads the fragment, and the web app +# has no part in approving. +/a /access-link.html 200 +/a/* /access-link.html 200 + # Everything else is hash-routed inside the shell, so there is no SPA fallback # to add. A request for an unknown path is genuinely not found. diff --git a/public/access-link.html b/public/access-link.html new file mode 100644 index 0000000..446b5c0 --- /dev/null +++ b/public/access-link.html @@ -0,0 +1,35 @@ + + + + + + + + Open this on your iPhone with FTW + + + + +
+

Open this on your iPhone with FTW

+

This link asks you to let an assistant reach your home. Open it on the iPhone where the FTW app is installed, and approve there with Face ID.

+

Or open FTW and type the code the page showed you: Settings, Assistants and support, Add an assistant.

+

Öppna länken på din iPhone med FTW. Där godkänner du med Face ID, eller skriver koden från sidan under Inställningar, Assistenter och support, Lägg till en assistent.

+
+ + diff --git a/tests/apple-app-site-association.test.ts b/tests/apple-app-site-association.test.ts index 6da6a0f..ec48ecb 100644 --- a/tests/apple-app-site-association.test.ts +++ b/tests/apple-app-site-association.test.ts @@ -26,16 +26,26 @@ describe('the apple-app-site-association file', () => { expect(aasa().webcredentials?.apps).toEqual([APP_ID]) }) - it('opens the app only for the pairing path the web app also serves', () => { + it('opens the app only for pairing and for an assistant\'s consent link', () => { 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']) + expect(paths).toEqual(['/p', '/a', '/a/*']) // 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('answers a consent link without the app with a page that sends you to it', () => { + const redirects = readFileSync('public/_redirects', 'utf8') + expect(redirects).toMatch(/^\/a \/access-link\.html 200$/m) + expect(redirects).toMatch(/^\/a\/\* \/access-link\.html 200$/m) + const page = readFileSync('public/access-link.html', 'utf8') + expect(page).toContain('Open this on your iPhone with FTW') + // It approves nothing and reads nothing: no script, no form, no fetch. + expect(page).not.toMatch(/ { const headers = readFileSync('public/_headers', 'utf8') const block = headers.split(/\n(?=\S)/).find(b => b.startsWith(`${PATH}\n`)) From e05e003dadc7510382844ab661aab15086abc542 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 10:30:25 +0200 Subject: [PATCH 2/4] AASA: /a only, the request rides in the fragment FTW Cloud makes only https://app.ftw.energy/a#.. A request id in a path would land in a server's log, and alone it is worth asking FTW Cloud about, so /a/* goes from the file and from the rewrites. Co-Authored-By: Claude Opus 5.5 --- .build/.buildSystem_debug | 1 + .build/CACHEDIR.TAG | 4 ++++ public/.well-known/apple-app-site-association | 3 +-- public/_redirects | 10 +++++----- tests/apple-app-site-association.test.ts | 5 +++-- 5 files changed, 14 insertions(+), 9 deletions(-) create mode 100644 .build/.buildSystem_debug create mode 100644 .build/CACHEDIR.TAG diff --git a/.build/.buildSystem_debug b/.build/.buildSystem_debug new file mode 100644 index 0000000..c337257 --- /dev/null +++ b/.build/.buildSystem_debug @@ -0,0 +1 @@ +swiftbuild \ No newline at end of file diff --git a/.build/CACHEDIR.TAG b/.build/CACHEDIR.TAG new file mode 100644 index 0000000..b7ab0d8 --- /dev/null +++ b/.build/CACHEDIR.TAG @@ -0,0 +1,4 @@ +Signature: 8a477f597d28d172789f06886806bc55 +# This file is a cache directory tag created by (Swift Package Manager). +# For information about cache directory tags, see: +#. http://www.brynosaurus.com/cachedir/ \ No newline at end of file diff --git a/public/.well-known/apple-app-site-association b/public/.well-known/apple-app-site-association index 6366448..4fe9478 100644 --- a/public/.well-known/apple-app-site-association +++ b/public/.well-known/apple-app-site-association @@ -8,8 +8,7 @@ "appIDs": ["L66M368PZ6.energy.ftw.app"], "components": [ { "/": "/p", "comment": "A pairing code scanned with the iPhone camera" }, - { "/": "/a", "comment": "An assistant's consent page asks the owner to approve in the app" }, - { "/": "/a/*", "comment": "The same, with the request in the path" } + { "/": "/a", "comment": "An assistant's consent page asks the owner to approve in the app" } ] } ] diff --git a/public/_redirects b/public/_redirects index 93df431..2860c72 100644 --- a/public/_redirects +++ b/public/_redirects @@ -10,12 +10,12 @@ /p /index.html 200 # /a is where an assistant's consent page at FTW Cloud sends the owner, with -# the signed offer in the fragment. The iPhone app opens it when installed -# (apple-app-site-association). Anywhere else this small page says to open -# it on the iPhone with FTW; it never reads the fragment, and the web app -# has no part in approving. +# the request and the signed offer in the fragment, which never reaches this +# server. The iPhone app opens it when installed (apple-app-site-association). +# Anywhere else this small page says to open it on the iPhone with FTW; it +# never reads the fragment, and the web app has no part in approving. There +# is no /a/: a request in a path would land in a log. /a /access-link.html 200 -/a/* /access-link.html 200 # Everything else is hash-routed inside the shell, so there is no SPA fallback # to add. A request for an unknown path is genuinely not found. diff --git a/tests/apple-app-site-association.test.ts b/tests/apple-app-site-association.test.ts index ec48ecb..a1e1a91 100644 --- a/tests/apple-app-site-association.test.ts +++ b/tests/apple-app-site-association.test.ts @@ -30,7 +30,8 @@ describe('the apple-app-site-association file', () => { 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', '/a', '/a/*']) + // Only /a itself: the request rides in the fragment, never a path. + expect(paths).toEqual(['/p', '/a']) // 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) @@ -39,7 +40,7 @@ describe('the apple-app-site-association file', () => { it('answers a consent link without the app with a page that sends you to it', () => { const redirects = readFileSync('public/_redirects', 'utf8') expect(redirects).toMatch(/^\/a \/access-link\.html 200$/m) - expect(redirects).toMatch(/^\/a\/\* \/access-link\.html 200$/m) + expect(redirects).not.toMatch(/^\/a\//m) const page = readFileSync('public/access-link.html', 'utf8') expect(page).toContain('Open this on your iPhone with FTW') // It approves nothing and reads nothing: no script, no form, no fetch. From ae68265a48ada124efa0c2f6cf1ac2a10a23665e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 10:31:30 +0200 Subject: [PATCH 3/4] Service worker: leave /a to the network A browser that once loaded the web app answered every navigation with the cached shell, so a consent link opened the app's shell instead of the page that says to open it on the iPhone (Codex review, P1). Co-Authored-By: Claude Opus 5.5 --- src/sw.ts | 5 +++++ tests/service-worker.test.ts | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/src/sw.ts b/src/sw.ts index e0aa77e..9401b66 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -77,6 +77,11 @@ sw.addEventListener('fetch', (event) => { const url = new URL(request.url) if (url.origin !== sw.location.origin) return + // An assistant's consent link (/a#.) is the iPhone app's, + // and its page here only says so. The network answers it, never the + // shell: the web app has no part in approving. + if (url.pathname === '/a') return + // Cache first, so opening the app never waits on a network that may not be // there. A build is only ever replaced wholesale, so this cannot serve a // shell that disagrees with the assets it will ask for next. diff --git a/tests/service-worker.test.ts b/tests/service-worker.test.ts index 37b6f0f..bcb64f7 100644 --- a/tests/service-worker.test.ts +++ b/tests/service-worker.test.ts @@ -88,6 +88,12 @@ describe('opening without a network', () => { expect(await answer(worker, request('/assets/index-a1.js'))).toEqual({ body: 'app a1' }) }) + it("leaves an assistant's consent link to the network, never the shell", async () => { + const worker = await activate('a1') + + expect(await answer(worker, request('/a', 'navigate'))).toBeNull() + }) + it('answers any navigation with the shell, whatever the path', async () => { const worker = await activate('a1') server = new Map() From 3e6ebccec198fa1b1bc4c0619906f11cbe8484fc Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 10:53:14 +0200 Subject: [PATCH 4/4] Rewrite /a to the page's own path, so it stays a 200 A rewrite to access-link.html came back as a 307 to /access-link on the preview: Cloudflare redirects a .html path to its bare one. Co-Authored-By: Claude Opus 5.5 --- public/_redirects | 6 +++++- tests/apple-app-site-association.test.ts | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/public/_redirects b/public/_redirects index 2860c72..d5186a5 100644 --- a/public/_redirects +++ b/public/_redirects @@ -15,7 +15,11 @@ # Anywhere else this small page says to open it on the iPhone with FTW; it # never reads the fragment, and the web app has no part in approving. There # is no /a/: a request in a path would land in a log. -/a /access-link.html 200 +# +# The target is the page's own path, without .html: Cloudflare serves +# access-link.html there and answers a .html path with a 307, which would +# turn this rewrite into a redirect. +/a /access-link 200 # Everything else is hash-routed inside the shell, so there is no SPA fallback # to add. A request for an unknown path is genuinely not found. diff --git a/tests/apple-app-site-association.test.ts b/tests/apple-app-site-association.test.ts index a1e1a91..3e98ca6 100644 --- a/tests/apple-app-site-association.test.ts +++ b/tests/apple-app-site-association.test.ts @@ -39,7 +39,8 @@ describe('the apple-app-site-association file', () => { it('answers a consent link without the app with a page that sends you to it', () => { const redirects = readFileSync('public/_redirects', 'utf8') - expect(redirects).toMatch(/^\/a \/access-link\.html 200$/m) + // Without .html: Cloudflare answers a .html path with a 307. + expect(redirects).toMatch(/^\/a \/access-link 200$/m) expect(redirects).not.toMatch(/^\/a\//m) const page = readFileSync('public/access-link.html', 'utf8') expect(page).toContain('Open this on your iPhone with FTW')