Sets up SAP Application Router (approuter) for Cloud Foundry web apps. Detects webapp/, web.xml, or HTML content; creates approuter dir with package.json, xs-app.json, and optional SAML2/Basic Auth middleware.
npx claudepluginhub sap-samples/btp-neo-java-app-migration --plugin sap-btp-neo-migrationThis skill is limited to using the following tools:
Set up the SAP Application Router (approuter) as the single entry-point for your Cloud Foundry application.
Guides Next.js Cache Components and Partial Prerendering (PPR): 'use cache' directives, cacheLife(), cacheTag(), revalidateTag() for caching, invalidation, static/dynamic optimization. Auto-activates on cacheComponents: true.
Processes PDFs: extracts text/tables/images, merges/splits/rotates pages, adds watermarks, creates/fills forms, encrypts/decrypts, OCRs scans. Activates on PDF mentions or output requests.
Share bugs, ideas, or general feedback.
Set up the SAP Application Router (approuter) as the single entry-point for your Cloud Foundry application.
The SAP Application Router is a key infrastructure component for web-facing applications running in Cloud Foundry on SAP BTP. It is used to:
In the Neo environment, the platform handled routing and authentication implicitly. In Cloud Foundry, you must explicitly set up the approuter as a separate Node.js module.
This skill applies if any of these conditions are true:
# Has a webapp directory with web content
find . -path "*/webapp/*" -type f | head -5
# Has HTML files served to users
find . -name "*.html" -path "*/webapp/*" | head -5
# Has web.xml (servlet-based app)
find . -name "web.xml" -path "*/WEB-INF/*" | head -5
<!-- web.xml contains auth-method -->
<auth-method>FORM</auth-method>
<!-- OR security-constraint definitions -->
<security-constraint>
<web-resource-collection>
<web-resource-name>Protected</web-resource-name>
<url-pattern>/protected/*</url-pattern>
</web-resource-collection>
</security-constraint>
Before invoking this skill, ensure you have invoked:
Use the sdk-replacement skill
Default to Standard. The extended approuter is rarely needed. XSUAA handles SAML, OIDC, and Basic auth challenges natively. Only choose Extended if you have confirmed that your backend application explicitly sends
WWW-Authenticateorcom.sap.cloud.security.logoutheaders in HTTP responses that require client-side redirect interception.
Before proceeding, determine which approuter variant your application needs:
| Scenario | Variant | Start Command |
|---|---|---|
| All endpoints require XSUAA auth | Standard | node node_modules/@sap/approuter/approuter.js |
| Mix of authenticated + public endpoints | Standard | node node_modules/@sap/approuter/approuter.js |
| Neo app used SAML2 + Basic Auth (common) | Standard | node node_modules/@sap/approuter/approuter.js |
Backend explicitly sends WWW-Authenticate headers that need client-side redirect | Extended | node server.js |
Backend explicitly sends com.sap.cloud.security.logout headers | Extended | node server.js |
Rule of thumb: Use Standard in almost all cases. The fact that the Neo application used SAML2 or Basic Auth does not mean you need the extended approuter — XSUAA handles these authentication methods natively. Only choose Extended if the user explicitly confirms the backend triggers
WWW-Authenticatechallenge headers that must be intercepted and redirected by middleware (this is very rare in CF).
Create a new approuter/ directory in the project root:
mkdir -p approuter
Create approuter/package.json — see assets/package.json:
{
"name": "approuter",
"dependencies": {
"@sap/approuter": "^16.0.0"
},
"scripts": {
"start": "node node_modules/@sap/approuter/approuter.js"
}
}
If you need the extended approuter, use a custom start script instead:
{
"name": "approuter",
"dependencies": {
"@sap/approuter": "^16.0.0"
},
"scripts": {
"start": "node server.js"
}
}
Note: The version of
@sap/approutermay be updated over time. Check the NPM package page for the latest version during the actual migration.
The xs-app.json file defines how the approuter routes requests to your backend application.
Create approuter/xs-app.json — see assets/xs-app.json:
{
"authenticationMethod": "route",
"routes": [
{
"source": "^(/.*)",
"target": "$1",
"destination": "<destination-name>",
"authenticationType": "xsuaa",
"csrfProtection": false
}
]
}
Note: Replace
<destination-name>with the name of the destination you will wire inmtad.yaml(e.g.,backend-app-destination). This destination proxies requests from the approuter to your Java backend.
If your application has both protected and public endpoints:
{
"authenticationMethod": "route",
"routes": [
{
"source": "^/protected(/.*)?$",
"target": "/protected$1",
"destination": "<destination-name>",
"authenticationType": "xsuaa",
"csrfProtection": false
},
{
"source": "^(/.*)",
"target": "$1",
"destination": "<destination-name>",
"authenticationType": "none",
"csrfProtection": false
}
],
"logout": {
"logoutEndpoint": "/logout",
"logoutPage": "/"
}
}
Important: Routes are matched top-to-bottom. Place more specific routes (e.g.,
/protected) above the catch-all route (^(/.*)).
If the backend's web.xml maps the JAX-RS/Jersey servlet to a specific prefix (e.g., /webapi/*), the approuter route must preserve that prefix in the target. A common mistake is writing a route that strips the prefix:
// WRONG — strips /webapi, backend servlet mapping won't match
{ "source": "^/webapi(/.*)?$", "target": "$1" }
// CORRECT — preserves /webapi, backend servlet mapping matches
{ "source": "^/webapi(/.*)?$", "target": "/webapi$1" }
On Neo there was no approuter — the browser called the backend directly and the servlet mapping matched naturally. On CF, the approuter sits in front, so its target must forward the path the backend expects. If the backend also serves static content (HTML, CSS, JS) from the WAR's webapp/ directory, do NOT change the servlet mapping to /* as this causes Jersey to intercept static resource requests, returning "Not Found".
Warning: Only use this template if you have explicitly verified the need for extended approuter (see decision table above). In most migrations, the standard xs-app.json is sufficient. Using the extended configuration unnecessarily introduces routing complexity and potential crashes.
For applications where the backend explicitly sends WWW-Authenticate or com.sap.cloud.security.logout headers — see assets/xs-app-extended.json:
{
"authenticationMethod": "route",
"routes": [
{
"source": "^/protected(/.*)?$",
"target": "/protected$1",
"destination": "<destination-name>",
"authenticationType": "xsuaa",
"csrfProtection": false
},
{
"source": "^/authentication/endpoint(/.*)",
"target": "$1",
"destination": "<destination-name>",
"authenticationType": "xsuaa",
"csrfProtection": false
},
{
"source": "^/basic/authentication/endpoint(/.*)",
"target": "$1",
"destination": "<destination-name>",
"authenticationType": "basic",
"csrfProtection": false
},
{
"source": "^/logout/callback",
"target": "/logout",
"destination": "<destination-name>",
"authenticationType": "none",
"csrfProtection": false
},
{
"source": "^(/.*)",
"target": "$1",
"destination": "<destination-name>",
"authenticationType": "none",
"csrfProtection": false
}
],
"logout": {
"logoutEndpoint": "/logout",
"logoutPage": "/logout/callback"
}
}
Skip this step if using the standard approuter.
Create approuter/server.js — see assets/server.js:
var approuter = require('@sap/approuter');
var ar = approuter();
ar.start({
extensions: [
require('./authentication-challenge-handler.js')
]
});
Create approuter/authentication-challenge-handler.js — see assets/authentication-challenge-handler.js.
This custom middleware handles:
WWW-Authenticate: SAML2 realm="Identity Authentication Service" headers from the backend and redirects the user to the XSUAA-authenticated endpoint.WWW-Authenticate: Basic realm="SAP HANA Cloud Platform" headers and redirects to a Basic-authenticated endpoint.com.sap.cloud.security.logout headers from the backend and processes logout redirect flows.function redirect(response, locationURL) {
console.log('Redirecting to: [' + locationURL + ']');
response.setHeader('Location', locationURL);
response.statusCode = 303;
response.end();
}
function handleAuthenticationChallenge(context, authenticateHeader) {
let incomingRequest = context.incomingRequest;
let incomingResponse = context.incomingResponse;
if (authenticateHeader.includes('SAML2 realm="Identity Authentication Service"')) {
console.log('Handling SAML2 (OIDC) authentication challenge.');
incomingResponse.setHeader('Location', '/authentication/endpoint' + incomingRequest.url);
incomingResponse.statusCode = 303;
} else if (authenticateHeader.includes('Basic realm="SAP HANA Cloud Platform"')) {
console.log('Handling BASIC (OIDC) authentication challenge.');
incomingResponse.setHeader('Location', '/basic/authentication/endpoint' + incomingRequest.url);
incomingResponse.statusCode = 303;
}
}
function handleLogout(context, logoutRequest) {
let incomingRequest = context.incomingRequest;
let incomingResponse = context.incomingResponse;
if (logoutRequest.includes('logout-request')) {
console.log('Triggering logout.');
incomingResponse.setHeader('Location', '/logout/endpoint?originalURL=' + incomingRequest.url);
incomingResponse.statusCode = 303;
}
}
module.exports = {
insertMiddleware: {
beforeRequestHandler: [
{
handler: function authenticationChallengeHandler(request, response, callNextHandler) {
console.log('Handling request with path: [' + request.url + ']');
if (request.url.startsWith('/authentication/endpoint')) {
let locationURL = request.url.substring('/authentication/endpoint'.length);
redirect(response, locationURL);
} else if (request.url.startsWith('/basic/authentication/endpoint')) {
let locationURL = request.url.substring('/basic/authentication/endpoint'.length);
redirect(response, locationURL);
} else if (request.url.startsWith('/logout/callback?originalURL=')) {
let locationURL = request.url.substring('/logout/callback?originalURL='.length);
redirect(response, locationURL);
} else {
request.afterRequestHandler = function (context, done) {
let outgoingResponse = context.outgoingResponse;
let authenticateHeader = outgoingResponse.headers['www-authenticate'];
let logoutRequest = outgoingResponse.headers['com.sap.cloud.security.logout'];
if (authenticateHeader !== undefined) {
handleAuthenticationChallenge(context, authenticateHeader);
}
if (logoutRequest !== undefined) {
handleLogout(context, logoutRequest);
}
done(null, context.incomingResponse);
};
callNextHandler();
}
}
}
]
}
};
Add the approuter module to your mtad.yaml. The Java backend must provide its URL, and the approuter requires it to create a destination:
modules:
# Java Backend Application
- name: <app-name>
type: java.tomcat
path: <path-to-war-file>
parameters:
buildpack: sap_java_buildpack_jakarta
disk-quota: 512MB
memory: 512MB
properties:
ENABLE_SECURITY_JAVA_API_V2: true
SET_LOGGING_LEVEL: 'ROOT: INFO'
requires:
- name: <app-name>-xsuaa
provides:
- name: <app-name>-java-app
properties:
java_app_url: '${default-url}'
# Application Router
- name: <app-name>-approuter
type: nodejs
path: approuter
parameters:
disk-quota: 256M
memory: 256M
routes:
- route: '${protocol}://<app-name>.${default-domain}'
protocol: http1
properties:
XS_APP_LOG_LEVEL: debug
requires:
- name: <app-name>-xsuaa
- name: <app-name>-java-app
group: destinations
properties:
name: <destination-name>
url: '~{java_app_url}'
forwardAuthToken: true
Note: Replace
<app-name>with your application name and<destination-name>with the destination name used inxs-app.json(e.g.,backend-app-destination).
Note: The
group: destinationsproperty creates a destination environment variable for the approuter at deployment time — no need to create a separate destination service instance for this internal routing.
After completing all steps, verify the directory structure:
approuter/
├── package.json
└── xs-app.json
approuter/
├── authentication-challenge-handler.js
├── package.json
├── server.js
└── xs-app.json
| File | Location | Purpose |
|---|---|---|
package.json | approuter/ | Node.js dependencies and start script |
xs-app.json | approuter/ | Routing configuration (routes, auth types) |
server.js | approuter/ | Extended approuter entry point (optional) |
authentication-challenge-handler.js | approuter/ | Custom middleware for auth challenges (optional) |
| Service | Plan | Purpose |
|---|---|---|
xsuaa | application | OAuth 2.0 authorization (bound to approuter) |
Note: The approuter itself requires an XSUAA service binding. The
authentication-xsuaaskill handles creating thexs-security.jsonand XSUAA service resource.
ls -la approuter/
# Should show package.json, xs-app.json, and optionally server.js + handler
cat approuter/xs-app.json | python3 -m json.tool
# Should parse without errors
mvn clean package
cf deploy . -f
https://<app-name>.<cf-domain>cf logs <app-name>-approuter --recentcf env <app-name>-approuter
# Should show destinations environment variable with backend URL
Cause: Backend application not reachable from the approuter.
Solution: Verify the provides/requires URL wiring in mtad.yaml. Check that the backend app is started: cf apps
Cause: Destination name in xs-app.json does not match the destination name in mtad.yaml.
Solution: Ensure <destination-name> is identical in both files.
Solution: Add CORS headers configuration to xs-app.json:
{
"cors": [
{
"uriPattern": "^/api/(.*)$",
"allowedOrigin": [{ "host": "*" }],
"allowedMethods": ["GET", "POST", "PUT", "DELETE"],
"allowedHeaders": ["Authorization", "Content-Type"]
}
]
}
Cause: Route patterns in xs-app.json conflict with middleware redirect paths.
Solution: Ensure the /authentication/endpoint and /basic/authentication/endpoint routes in xs-app.json match the paths handled by authentication-challenge-handler.js.
Solution: Add the pattern as a property in the approuter module in mtad.yaml:
properties:
TENANT_HOST_PATTERN: '(.*).cfapps.sap.hana.ondemand.com'
Cause: The approuter forwards the request correctly, and the HTML page loads (200 OK), but the page appears blank. This is typically caused by SAPUI5 not loading:
com.sap.ui5.resource.ResourceServlet served SAPUI5 at /resources/*. In CF, that servlet doesn't exist, so <script src="resources/sap-ui-core.js"> returns 404 silently.sapui5.hana.ondemand.com no longer serves older SAPUI5 versions (pre-1.71) — pinned versions like 1.38.x return 404.sap_bluecrystal theme was removed in SAPUI5 1.40+ and won't load.Diagnosis: Open the browser developer tools (F12 → Network tab). Look for a 404 on sap-ui-core.js or theme CSS files.
Solution: Update all HTML files to load SAPUI5 from the current CDN domain, use a supported version, and replace deprecated themes:
<script src="https://ui5.sap.com/1.120.0/resources/sap-ui-core.js" ...
data-sap-ui-theme="sap_fiori_3" ...>
See the sdk-replacement skill, Step 6: Migrate SAPUI5 ResourceServlet for full instructions and bulk-fix commands.
Cause: The backend's JAX-RS/CXF servlet is declared in web.xml but has no <servlet-mapping>. The Neo web.xml may have had a mapping that was accidentally omitted during the copy to the CF project. Without the mapping, API endpoints like /rest/* are unreachable.
Diagnosis: Check src/main/webapp/WEB-INF/web.xml — if there is a <servlet> for CXFServlet but no <servlet-mapping>, that's the problem.
Solution: Add the missing servlet mapping:
<servlet-mapping>
<servlet-name>CXFServlet</servlet-name>
<url-pattern>/rest/*</url-pattern>
</servlet-mapping>
See the sdk-replacement skill, Step 7: Verify Servlet Mappings for details.
Cause: The approuter's xs-app.json route uses a target pattern like "target": "$1" that strips a path prefix (e.g., /webapi). On Neo there was no approuter, so the browser called the backend directly at /webapi/cnbrest/v1/... and the servlet mapping /webapi/* matched. On CF, the approuter strips the prefix and the request arrives at the backend as /cnbrest/v1/..., which doesn't match the old servlet mapping.
Diagnosis:
# Check if xs-app.json strips a prefix
cat approuter/xs-app.json
# A route like: "source": "^/webapi(/.*)?$", "target": "$1"
# strips /webapi — request arrives at backend without it
# Check servlet mapping
grep -A2 "servlet-mapping" src/main/webapp/WEB-INF/web.xml
Solution: Fix the approuter route to preserve the path prefix by including it in the target:
{
"source": "^/webapi(/.*)?$",
"target": "/webapi$1",
"destination": "backend-app-destination"
}
This way the request path arrives at the backend unchanged and the existing servlet mapping /webapi/* continues to work.
Warning: Do NOT fix this by changing the servlet mapping from
/webapi/*to/*. While that makes API calls work, it causes Jersey to intercept ALL requests including static content (HTML, CSS, JS), returning "Not Found" for non-API paths. The backend typically serves both static content (via Tomcat's default servlet) and REST APIs (via the Jersey servlet). Mapping Jersey to/*breaks static content serving.
After completing this skill, proceed to: