ReviewOS

stacks/bench-review

mobile: universal links are entitled but no apple-app-site-association is served

#80
Open glennmichael123 opened this 9 days ago ·
9 days ago

Part of #79.

Problem

config/mobile.ts declares the entitlement:

associatedDomains: personalTeam ? [] : ['applinks:benchreview.org'],
urlSchemes: ['benchreview'],

Nothing serves the other half. Universal links need https://benchreview.org/.well-known/apple-app-site-association — a JSON file naming <APPLE_TEAM_ID>.<IOS_BUNDLE_ID> and the paths it claims. Without it iOS silently declines to open links in the app and falls back to Safari, with no error anywhere: the entitlement is present, the association is missing, and the failure is invisible.

public/.well-known/ does not exist in this repo today.

What to do

Port scripts/generate-mobile-associations.ts from the wildloop reference (58 lines). It reads APPLE_TEAM_ID / IOS_BUNDLE_ID from the environment and writes public/.well-known/apple-app-site-association at build time, so the file ships with the static artifact rather than being hand-maintained.

Decide which paths the app claims. Candidates, given bench's routes:

  • /judges/* and /judges/*/profile|reviews|rulings|cases — the main share targets (navigator.share is already called from Bench/Judge/ProfileHeader.stx)
  • /court-houses/*
  • /article/* — published reviews
  • NOT /admin/*, and NOT the auth pages (/login, /reset-password, /verify-email/*): a password-reset link opening in an app shell rather than the browser is a worse flow, and an emailed verification link should stay in the browser that requested it.

Checks

  • File served at /.well-known/apple-app-site-association with Content-Type: application/json and no redirect (iOS does not follow one)
  • appID matches the built app's team + bundle id exactly
  • A link to a judge profile, tapped from Messages, opens the app on the right page
  • The IOS_PERSONAL_TEAM=1 path still builds — a free Apple team cannot sign Associated Domains, which is why config/mobile.ts empties the array in that case
  • The gate's sitemap/robots checks still pass with .well-known/ present

Done (a1b1a6bc, bcc8cb2d)

Ported and adapted rather than copied — wildloop's script claims {"/": "/*"}, everything, which is the wrong policy for a site with a moderation surface.

Written into dist/ during the build, not into public/. It cannot then be committed stale and cannot drift from the app's identity. The part most likely to fail quietly was whether buildApp() carries a dot-directory through to dist/ at all — many copy routines skip hidden files. Verified: it does.

Path policy. /judges/*, /court-houses/* and /article/* are claimed. Excluded: /admin/*, because it is where an anonymized reviewer can be unmasked and that belongs in a browser the person chose rather than whatever app intercepted a tap; and the auth paths, because a reset or verification link is a one-time credential that belongs in the browser that asked for it.

Ordering is load-bearing. iOS takes the first matching component, so an exclusion listed after the include it carves out of is never reached — it sits in the file doing nothing. Both the unit test and gate check 27 pin it, because that mistake cannot be seen by reading the file casually.

webcredentials is deliberately absent. config/mobile.ts entitles applinks: only, and claiming both would assert a capability the signed app does not have.

The check I cannot tick, and why

  • A link to a judge profile, tapped from Messages, opens the app on the right page

Needs a real device, a signed build with a non-personal team, and benchreview.org actually serving the file — the deploy is #85/#45. Nothing I can do locally substitutes for it, so it stays open.

The check only a host can satisfy

  • Served with Content-Type: application/json and no redirect

The file is in the artifact, but it has no extension by Apple's requirement, so a host inferring the type from one serves application/octet-stream. Measured — that is exactly what Bun's static file handling does with it. iOS rejects anything that is not application/json, at that exact path, and does not follow redirects.

Added to DEPLOY.md as a fourth item in the deploy contract, with Caddy and nginx snippets. Gate check 27 verifies the artifact; only the host can get the content type right, and nothing will report it when it is wrong.

Gate check 27

Present, valid JSON, appIDs matching the built team+bundle, exclusions ahead of inclusions, /admin never claimed. Falsified on all four branches. Reported rather than failed when APPLE_TEAM_ID is unset, matching the Turnstile check — a checkout that is not building the app has no team id, and failing every local build over that would teach people to ignore the gate.

8 days ago

/reviews is claimed now (632b116f)

The feed became the app's start path and the first tab (19aeb9a8), and this file did not claim it — so a universal link to the screen the app opens on fell back to Safari. That is the one link most likely to be shared.

Claimed exactly, not only by prefix

EXCLUDE /admin/*
EXCLUDE /login
EXCLUDE /register
EXCLUDE /forgot-password
EXCLUDE /reset-password*
EXCLUDE /verify-email/*
claim   /reviews          ← new
claim   /reviews/*        ← new, the comment threads
claim   /judges/*
claim   /court-houses/*
claim   /article/*

/reviews/* does not match /reviews. A prefix component needs something after the slash, so claiming only the prefix would have left the bare path opening in Safari while the file looked like it covered it. Pinned by a test, because that is not visible by reading the file.

That asymmetry is why the directories are treated differently: /judges and /court-houses are index pages nobody shares, and their detail pages — the actual share targets — are already carried by their prefixes.

A second test asserts the API was not swallowed on the way: /api/reviews is on the same host, and a /* or a slashless /reviews* would hand the app a link it cannot draw.

Ordering unchanged, and still load-bearing. Gate check 27's two assertions (excludes before includes, /admin never claimed) both still hold.

The two checks on this issue are unchanged

  • Served with Content-Type: application/json and no redirect — the file has no extension by Apple's requirement, so a host inferring the type serves application/octet-stream. Still a host concern.
  • A link tapped from Messages opens the app on the right page — needs a signed non-personal team and benchreview.org actually serving the file (#85/#45).

Nothing here can be verified end-to-end until the deploy lands. What changed is that the policy is now right, and wrong-by-omission is no longer one of the reasons it could fail.

Sign in to comment on this issue.