mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
8d6f1cf8d8
commit
014593a2a1
2 files changed
+158
No files matched your search
@@ -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`).
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user