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/.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..4fe9478 100644 --- a/public/.well-known/apple-app-site-association +++ b/public/.well-known/apple-app-site-association @@ -7,7 +7,8 @@ { "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" } ] } ] diff --git a/public/_redirects b/public/_redirects index 420b1b8..d5186a5 100644 --- a/public/_redirects +++ b/public/_redirects @@ -9,5 +9,17 @@ # 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 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. +# +# 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/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/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/apple-app-site-association.test.ts b/tests/apple-app-site-association.test.ts index 6da6a0f..3e98ca6 100644 --- a/tests/apple-app-site-association.test.ts +++ b/tests/apple-app-site-association.test.ts @@ -26,16 +26,28 @@ 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']) + // 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) }) + it('answers a consent link without the app with a page that sends you to it', () => { + const redirects = readFileSync('public/_redirects', 'utf8') + // 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') + // 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`)) 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()