Skip to content

Serving assetlinks.json from the wrong path

Published

On 2026-10-06 the dev.to account daniel_pertu published a build report about CogniPrep, which the report describes as a Next.js app for practising employer assessments, and the subject of the report is that the file proving the app owns its domain is served from the wrong URL. The report states that cogniprep.app/.well-known/assetlinks.json returns 404 and that cogniprep.app/api/.well-known/assetlinks.json returns 200 with content-type: application/json and the real contents, so the JSON itself is correct and only its address is wrong (the report).

The report identifies the missing root-path file as a configuration that would cause verification to fail, and also examines the app’s manifest and how the Android build is declared to mobile browsers. Each finding is taken in turn below, in the terms the report and the platform documentation it concerns actually use (the report).

The report states that CogniPrep ships as a website first and that there is an Android wrapper built as a Trusted Web Activity, which it describes as Chrome’s way of putting a web app in the Play Store without a WebView of your own. It states that a Trusted Web Activity runs the site fullscreen with no address bar, but only if it can prove that the Android package and the domain belong to the same owner, and that the proof is Digital Asset Links: one JSON file, fetched over HTTPS, from exactly one path, which it gives as https://<your-domain>/.well-known/assetlinks.json (the report).

Google’s own Digital Asset Links documentation states the same location in stricter terms. It describes the protocol and API as enabling an app or website to make public, verifiable statements about other apps or websites, and says statements are contained in a statement list that is cleartext and publicly accessible, in a location that is controlled by the principal and difficult to spoof or tamper with. In its usage example the website publishes a statement list at https://www.example.com/.well-known/assetlinks.json, and the page states that this is the official name and location for a statement list on a site, and that statement lists in any other location, or with any other name, are not valid for that site. The example statement grants the Android app permission to open links on the site, with the relation delegate_permission/common.handle_all_urls and a target naming the android_app namespace, a package_name, and sha256_cert_fingerprints, which the page describes as the SHA256 fingerprints of the app’s signing certificate (Google, Digital Asset Links getting started).

The route handler the report prints matches that shape. It returns a literal with relation: ['delegate_permission/common.handle_all_urls'] and a target giving namespace: 'android_app', package_name: 'app.cogniprep.twa', and a sha256_cert_fingerprints array, and the report’s own verdict on it is right relation, right namespace, right package, a real signing certificate fingerprint, served from the wrong URL (the report).

The report states that the file lives at app/api/.well-known/assetlinks.json/route.ts, that App Router maps directories to URL segments, and that 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. Its conclusion is that nothing is broken and Next.js did exactly what the folder structure asked for (the report).

The report locates the mistake in the api/ at the front of that path. It states that every other server endpoint in the app lives under app/api/, because that is where endpoints go, and that a file returning JSON from a GET handler reads like an endpoint but is not one: it is a static well-known resource that happens to be generated by code, and the thing consuming it is not the frontend. The report states that Android’s verification service has no configuration, no base path, and no override, and that it fetches /.well-known/assetlinks.json or it fails (the report).

The fixes are each described as one change. The first is to move the folder to app/.well-known/assetlinks.json/route.ts, on the grounds that the route path is the folder path. The second is to leave the handler where it is and add a rewrites() entry in next.config.mjs mapping /.well-known/assetlinks.json to /api/.well-known/assetlinks.json. The report prefers the move, and its stated reason is that a rewrite is a second place that has to stay true and that there is nothing API-shaped about the file (the report).

The report states that a missing assetlinks.json does not throw, does not log, and does not 500, that the web app is completely unaffected, and that every browser visitor gets the same site they always did. It states that the only symptom lives on an Android device, where a Trusted Web Activity that fails verification does not crash but degrades, opening the site in a Custom Tab with the URL bar visible, so the app still works and stops looking like an app (the report).

Chrome’s Trusted Web Activity quick start documents that fallback as the designed behaviour rather than a quirk of this app. It states that a Trusted Web Activity can launch a full screen browser tab without any browser UI, that the capability is restricted to websites you own, and that ownership is proven by setting up Digital Asset Links. It states that when a Trusted Web Activity is launched the browser verifies the Digital Asset Links, and that if verification fails the browser falls back to displaying the website as a Custom Tab, which it elsewhere describes as launching the website as a Custom Tab with browser UI at the top of the page. The same page instructs that after creating the assetlinks.json file it be uploaded to the website at .well-known/assetlinks.json relative to the root, and names using the wrong signature as a common cause of verification failing (Chrome, Trusted Web Activity quick start).

The report adds that the app’s own crawl rules hide the working copy. It states that app/robots.ts emits Disallow: /api/ and Disallow: /*.json, so the one URL that does serve the file is excluded twice over, by prefix and by extension, and that there are 740 Disallow lines in the emitted file. It states that Android does not read robots.txt, so this does not cause the failure, but that no crawl of the site will surface the working copy or notice the missing one. It also identifies a stale rule, Disallow: /manifest.ts, which it says blocks nothing at all, because app/manifest.ts is a Next.js file convention and the URL it produces is /manifest.webmanifest, and a rule written against the source filename cannot match a request path (the report).

The report prints the app’s icon array, which declares /logo-light.png at 512x512 and image/png twice, once with "purpose": "any" and once with "purpose": "maskable". It states that any is drawn as-is, that maskable is drawn into whatever shape the launcher wants and needs roughly 20 percent of padding on every side to survive the crop, and that reusing one square logo for both means the maskable path is cropping artwork that was never laid out for it. Its verdict is that listing it is not wrong, but that it is a claim about the image rather than a property of the manifest, and that nothing validates it (the report).

MDN documents the member that claim is made through. It describes purpose as a case-sensitive keyword string that specifies one or more contexts in which the icon can be used by the browser or operating system, states that browsers use these values as hints to determine where and how an icon is displayed, and defines maskable as indicating that the icon is designed with icon masks and safe zone in mind, such that any part of the image outside the safe zone can be ignored and masked away. It defines any as indicating that the icon can be used in any context, and states that this is the default value (MDN, icons).

The second manifest finding is the name. The report states that the manifest says CogniPrep - Arctic Shores Practice and describes “all 14 Arctic Shores psychometric games”, that this was true when it was written, and that the app now covers dozens of assessment providers. It states that the installed app’s home screen label is a string that nothing renders in the website, so looking at the site will never show that it drifted. It also states that there is no related_applications entry, so a mobile browser visiting the site has no way to know the Android build exists (the report).

MDN describes related_applications as the manifest member used to specify one or more applications that are related to a web application, which may be platform-specific applications or Progressive Web Apps, and states that it enables the use of web APIs like Navigator.getInstalledRelatedApps() to check whether a platform-specific version of the web app, or the web app itself, is already installed on the device. MDN lists play among the example platform values and marks the member experimental (MDN, related_applications).

The report names a shape shared by its findings: a value that is only ever consumed by something outside the browser. It states that the verification file is read by Android, that the manifest name is read by a launcher, and that the maskable purpose is honoured by an icon mask the developer does not control, and that none of them appear on a page, none fail a type check, and none are exercised by a test that renders the app. Its stated consequence is that a route handler returning a 200 with correct JSON feels finished, and that the only way to find out it is finished at the wrong address is to fetch the address the consumer actually uses, for which it gives curl https://yourdomain.com/.well-known/assetlinks.json (the report).

The platform documentation makes the same point from the consumer’s side. Google states that the statement list derives its value from being in a location controlled by the principal: the protocol does not authenticate the principal making the statement, but the statement is located in a specific location strongly associated with the principal and under the principal’s control. A path the principal chose for its own convenience is not that location, and Google’s page states outright that a statement list in any other location, or with any other name, is not valid for the site (Google, Digital Asset Links getting started).

Chrome’s quick start supplies the diagnostic that distinguishes the two ways a Trusted Web Activity can fail. It states that a Trusted Web Activity will try to adhere to the user’s default choice of browser, that if the default browser supports Trusted Web Activities it will be launched, that failing that any installed browser supporting them will be chosen, and that the default behaviour is to fall back to a Custom Tabs mode. For that reason it instructs developers debugging Trusted Web Activities to confirm which browser is in use, and prints adb logcat -v brief | grep -e TWAProviderPicker as the way to check (Chrome, Trusted Web Activity quick start).

  • Capabilities: follow this cross-reference alongside the report’s discussion of Trusted Web Activity integration.
  • Storage: follow this cross-reference alongside the report’s discussion of a well-known file at a fixed origin-relative path.
  • Performance: the report states its 404 is served as a full HTML page with a stylesheet and about a dozen script tags; follow this cross-reference alongside that finding.
  • Whether the missing /.well-known/assetlinks.json is the only reason CogniPrep’s Trusted Web Activity fails verification, rather than one of several, is not confirmed. Chrome’s quick start names the wrong signing signature as a common cause of the same failure.
  • Whether the Android package app.cogniprep.twa is signed with the certificate whose fingerprint the route handler returns is not confirmed; the report prints the fingerprint truncated.
  • Whether moving the route handler to app/.well-known/assetlinks.json/route.ts makes verification succeed on any particular browser and Android version is not stated by the report.
  • Whether the roughly 20 percent of padding on every side that the report attributes to the maskable path corresponds to the safe zone any specific launcher applies is not confirmed.
  • Whether a related_applications entry would change what a mobile browser shows for this site is not confirmed; MDN marks the member experimental.
  • Whether the 740 Disallow lines the report counts in its emitted robots.txt affect anything other than crawling is not addressed.