test(aasa): pin payload shape and content-type

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 11:38:58 +02:00
1 parent 8d6f1cf8d8
commit 014593a2a1
2 files changed
+158

No files matched your search

+73
View File
@@ -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 <changed files>` — 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`).
+85
View File
@@ -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
* `<TeamID>.<BundleID>` 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]);
});
});