CogniPrep is a Next.js app for practising employer assessments. It ships as a website first, and there is an Android wrapper built as a Trusted Web Activity, which is Chrome's way of putting a web app in the Play Store without a WebView of your own. A TWA runs your site fullscreen with no address bar, but only if it can prove that the Android package and the domain belong to the same owner.
That proof is Digital Asset Links. One JSON file, fetched over HTTPS, from exactly one path:
https://<your-domain>/.well-known/assetlinks.json
Ours is not there. You can check in a browser tab right now:
- cogniprep.app/.well-known/assetlinks.json returns 404, and because this is Next.js, the 404 is a full HTML page with a stylesheet and a dozen script tags
-
cogniprep.app/api/.well-known/assetlinks.json returns 200 with
content-type: application/jsonand the real contents
The file itself is correct. It is a route handler that returns a literal:
export async function GET() {
const assetLinks = [
{
relation: ['delegate_permission/common.handle_all_urls'],
target: {
namespace: 'android_app',
package_name: 'app.cogniprep.twa',
sha256_cert_fingerprints: ['8C:7E:D1:6F:...:6D:81'],
},
},
];
return Response.json(assetLinks);
}
Right relation, right namespace, right package, a real signing certificate fingerprint. Served from the wrong URL.
Why it is at the wrong URL
The file lives at app/api/.well-known/assetlinks.json/route.ts.
Read that path slowly. App Router maps directories to URL segments, and it is perfectly happy with directory names containing dots. So .well-known is a segment, assetlinks.json is a segment, and the resulting route is /api/.well-known/assetlinks.json. Nothing is broken. Next.js did exactly what the folder structure asked for.
The mistake is the api/ at the front. Every other server endpoint in the app lives under app/api/, because that is where endpoints go, and a file that returns JSON from a GET handler reads like an endpoint. It is not. It is a static well-known resource that happens to be generated by code, and the thing consuming it is not our frontend. It is Android's verification service, which has no configuration, no base path and no override. It fetches /.well-known/assetlinks.json or it fails.
Two fixes, both one change:
// Option 1: move the folder. The route path is the folder path.
app/.well-known/assetlinks.json/route.ts
// Option 2: leave it and add a rewrite in next.config.mjs
async rewrites() {
return [
{
source: '/.well-known/assetlinks.json',
destination: '/api/.well-known/assetlinks.json',
},
];
}
Option 1 is better, because a rewrite is a second place that has to stay true. There is nothing API-shaped about this file.
Nothing in our tooling was ever going to catch this
This is the part worth taking away, because the bug is trivial and the silence around it is not.
A missing assetlinks.json does not throw. It does not log. It does not 500. The web app is completely unaffected: every browser visitor gets the same site they always did. The only symptom lives on an Android device, where a TWA that fails verification does not crash, it degrades. It opens your site in a Custom Tab with the URL bar visible. The app still works. It just stops looking like an app.
And our own crawl rules actively hide it. Here is what app/robots.ts emits, trimmed to the relevant lines:
Disallow: /api/
...
Disallow: /*.json
So the one URL that does serve the file is excluded twice over, by prefix and by extension. Android does not read robots.txt, so this does not cause the failure. But it does mean that no crawl of the site, ours or anyone else's, will ever surface the working copy or notice the missing one. You can read the whole file at cogniprep.app/robots.txt if you want to count the Disallow lines; there are 740 of them.
There is also a stale line in there, Disallow: /manifest.ts, which blocks nothing at all. app/manifest.ts is a Next.js file convention and the URL it produces is /manifest.webmanifest. A rule written against the source filename cannot match a request path.
While we were in there: the manifest
The other file a TWA leans on is the web app manifest, and ours is served at cogniprep.app/manifest.webmanifest. Open it and you can see two more things that are worth checking in your own:
One icon, declared twice.
"icons": [
{ "src": "/logo-light.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/logo-light.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
Same artwork for both purposes. any is drawn as-is; maskable is drawn into whatever shape the launcher wants and needs roughly 20 percent of padding on every side to survive the crop. Reusing one square logo for both means the maskable path is cropping artwork that was never laid out for it. Listing it is not wrong, but it is a claim about the image, not a property of the manifest, and nothing validates it for you.
A name that is older than the product. The manifest says CogniPrep - Arctic Shores Practice and describes "all 14 Arctic Shores psychometric games". That was true when it was written. The app now covers dozens of assessment providers, which you can see on cogniprep.app/games. The installed app's home screen label is a string that nothing renders in the website, so no amount of looking at the site will ever show you that it drifted.
There is no related_applications entry either, so a mobile browser visiting the site has no way to know the Android build exists.
The pattern
Three of these four problems share one shape: a value that is only ever consumed by something outside the browser.
The verification file is read by Android. The manifest name is read by a launcher. The maskable purpose is honoured by an icon mask you do not control. None of them appear on a page, none of them fail a type check, and none of them are exercised by a test that renders your app. A route handler that returns a 200 with correct JSON feels finished, and the only way to find out that it is finished at the wrong address is to fetch the address the consumer actually uses.
So fetch it. curl https://yourdomain.com/.well-known/assetlinks.json takes a second, and it is the only check that is written from the consumer's point of view rather than yours.
If you want to see the broken and working pair side by side, the two URLs are at the top of this post, and the practice site they belong to is cogniprep.app.
Top comments (0)