From 014593a2a1c4945723af2814465d76ad98bebc26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 11:38:58 +0200 Subject: [PATCH] test(aasa): pin payload shape and content-type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five assertions on the route handler: - 200 on an unauthenticated GET. - Content-Type is exactly application/json with no charset parameter (Apple's swcd/aasa-validator refuse a charset and the failure mode is silent — Universal Links just stop firing). - Cache-Control allows public CDN mirroring. - JSON shape matches the SB-4 spec exactly. - applinks App ID matches webcredentials App ID so a rotated Team ID never half-lands. Includes the v1.4.40 phase report. --- .planning/phase-W-AASA-v1440-report.md | 73 ++++++++++++++++++++++ src/__tests__/api/well-known.test.ts | 85 ++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 .planning/phase-W-AASA-v1440-report.md create mode 100644 src/__tests__/api/well-known.test.ts diff --git a/.planning/phase-W-AASA-v1440-report.md b/.planning/phase-W-AASA-v1440-report.md new file mode 100644 index 000000000..9a3d0bc21 --- /dev/null +++ b/.planning/phase-W-AASA-v1440-report.md @@ -0,0 +1,73 @@ +# Phase W-AASA — v1.4.40 — Apple App Site Association (SB-4) + +Agent: W-AASA. Single-axis scope: serve the AASA payload required by +iOS Universal Links and Web Credentials so iOS PB30 (App-Store +submission) is unblocked. + +## What changed + +- `src/app/.well-known/apple-app-site-association/route.ts` — already + existed as a route handler (the preferred Next.js App-Router pattern + for an extension-less `/.well-known/*` asset). The previous body + carried an empty `applinks.details` array, which let passkey + Web-Credentials work but left Universal Links unwired. The handler + now populates `applinks.details` with the SB-4 App ID + (`S8WDX4W5KX.dev.healthlog.app`) and the `["*"]` path matcher, so + every HealthLog URL becomes a Universal Link candidate on devices + that have the app installed. Both the `applinks` and + `webcredentials.apps` entries share the same App ID prefix via a + local `AASA_APP_ID` constant — splitting them silently breaks + passkey login on iOS. + +- `src/__tests__/api/well-known.test.ts` — new five-assertion unit test + that pins: + - HTTP 200 on a bare `GET`. + - `Content-Type: application/json` exactly (no `charset` parameter + — Apple's swcd / aasa-validator refuse to mirror anything + annotated with `charset=…`, and the symptom is silent: Universal + Links just stop firing). + - A `public, max-age=N` `Cache-Control` that lets Apple's CDN + mirror the file. + - The exact JSON shape from the SB-4 spec. + - `applinks.details[0].appID === webcredentials.apps[0]` — guard + against the maintainer accidentally rotating one App ID without + the other. + +## Implementation choice — route handler over `public/` + +`grep` for `.well-known` surfaced an existing route handler at +`src/app/.well-known/apple-app-site-association/route.ts` (added in +v1.4.33) plus an existing proxy guard in +`src/__tests__/proxy-well-known-public.test.ts` that admits the path +through `proxy.ts` without a session cookie. The route-handler +convention is already the house style and is the right call for an +extension-less file (Next.js does not serve extension-less files from +`public/` cleanly, and a route handler lets us set the exact +`Content-Type` and `Cache-Control` headers Apple requires). No +duplicate file was added under `public/`. + +## Quality gates + +- `pnpm vitest run src/__tests__/api/well-known.test.ts` — 5/5 pass. +- `pnpm vitest run src/__tests__/proxy-well-known-public.test.ts` — + 4/4 pass (existing AASA proxy guard still green). +- `pnpm tsc --noEmit` — clean. +- `pnpm exec eslint ` — clean. + +## Commits on `develop` + +- `feat(aasa): serve apple-app-site-association at the well-known path` +- `test(aasa): pin payload shape and content-type` + +## Out of scope (deliberate) + +- No change to `src/proxy.ts` — the existing + `WELL_KNOWN_PUBLIC_PATHS` allowlist already exposes the path + unauthenticated, and the matcher in `proxy.ts:280–284` excludes + static-file extensions but lets extension-less paths through. +- No change to `CHANGELOG.md` or `package.json` — SB-4 is one slice of + the v1.4.40 release and the release-master will bundle the version + bump. +- No iOS-side entitlement edits — the iOS team's PB30 phase owns the + `Associated Domains` entitlement (`applinks:healthlog.bombeck.io` + and `webcredentials:healthlog.bombeck.io`). diff --git a/src/__tests__/api/well-known.test.ts b/src/__tests__/api/well-known.test.ts new file mode 100644 index 000000000..c9e2bf5f5 --- /dev/null +++ b/src/__tests__/api/well-known.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; + +/** + * SB-4 (v1.4.40) — Apple App Site Association (AASA) handler contract. + * + * iOS Universal Links and Web Credentials both fetch + * `/.well-known/apple-app-site-association` without credentials and feed + * the body to Apple's swcd / aasa-validator. Both pieces of Apple + * tooling are strict about three things: + * + * 1. The exact JSON shape — `applinks.details[].appID` (the + * `.` prefix) plus `applinks.details[].paths` + * (the URL-prefix allow-list), and `webcredentials.apps` (the same + * App ID prefix) for passkey origin-sharing. + * 2. The `Content-Type` header — must be `application/json` *without* + * a `charset` parameter. `application/json; charset=utf-8` makes + * Apple's CDN proxy refuse to mirror the file, which silently + * breaks Universal Links on every device. + * 3. The response answers 200 to an unauthenticated GET (covered by + * `proxy-well-known-public.test.ts` at the proxy layer). + * + * This file pins (1) and (2). Any drift means iOS PB30 (App-Store + * submission) regresses without a CI signal. + */ + +import { GET } from "@/app/.well-known/apple-app-site-association/route"; + +const APP_ID = "S8WDX4W5KX.dev.healthlog.app"; + +describe("/.well-known/apple-app-site-association (SB-4)", () => { + it("answers 200", async () => { + const res = GET(); + expect(res.status).toBe(200); + }); + + it("uses Content-Type: application/json with no charset", async () => { + const res = GET(); + const contentType = res.headers.get("content-type"); + // Apple's aasa-validator + swcd reject any `charset=…` parameter + // on the AASA file. The handler MUST emit the bare media type. + expect(contentType).toBe("application/json"); + }); + + it("sets a Cache-Control directive that allows mirroring", async () => { + const res = GET(); + const cacheControl = res.headers.get("cache-control"); + expect(cacheControl).toBeTruthy(); + // Public + max-age so Apple's CDN (one-hour TTL) can mirror the + // file without falling back to private/no-store behaviour. + expect(cacheControl).toMatch(/public/); + expect(cacheControl).toMatch(/max-age=\d+/); + }); + + it("emits the SB-4 payload shape exactly", async () => { + const res = GET(); + const body = await res.json(); + expect(body).toEqual({ + applinks: { + details: [ + { + appID: APP_ID, + paths: ["*"], + }, + ], + }, + webcredentials: { + apps: [APP_ID], + }, + }); + }); + + it("uses the same App ID prefix for applinks and webcredentials", async () => { + // Apple's passkey origin-pairing requires the iOS bundle and the + // website origin to advertise the same App ID. Splitting these two + // entries (e.g. a stale Team ID on one side) breaks passkey login + // without a redirect / status-code signal — the only symptom is a + // silent fall-back to password login on iOS. + const res = GET(); + const body = (await res.json()) as { + applinks: { details: Array<{ appID: string }> }; + webcredentials: { apps: string[] }; + }; + expect(body.applinks.details[0].appID).toBe(body.webcredentials.apps[0]); + }); +});