diff --git a/AGENTS.md b/AGENTS.md
index 4076853..c7e2bd9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -19,7 +19,7 @@ VitePress build. It is also what CI runs, on every pull request
- every `require("@cap2ui5/cds-plugin")` or `import { … } from
"@cap2ui5/cds-plugin"` **inside a code fence** names only what the package
really exports (and two dead packages are reported, in either form: the
- port's `abap2UI5/…` and the plugin's withdrawn old name `cap2ui5`),
+ port's `abap2UI5/…` and the plugin's old name `cap2ui5`, a deprecated placeholder on npm),
- every `cds.requires.cap2ui5.` is an option the plugin defines (and
0.1.0's `cds.cap2ui5. ` is reported as the deprecated place),
- every `1.x.y` release number is the pinned runtime release,
@@ -62,12 +62,18 @@ against the repos, don't guess):
| [abap2UI5/abap2UI5](https://github.com/abap2UI5/abap2UI5) | the framework itself, in ABAP. Downported and transpiled, it is published as `@abap2ui5/node-runtime` |
| [cap2UI5/docs](https://github.com/cap2UI5/docs) | this site |
-On npm since 2026-09-29: `@cap2ui5/cds-plugin@0.3.1` (Node ≥ 22, peer
+On npm: `@cap2ui5/cds-plugin@0.4.0` (since 2026-09-30; Node ≥ 22, peer
`@sap/cds` ≥ 9), published by the npm organisation `cap2ui5`, and
-`@cap2ui5/samples@0.1.0`. The plugin pins `@abap2ui5/node-runtime@1.145.0`
-(on npm since 2026-09-27) **exactly**. Up to 0.2.0 the plugin was the unscoped
-package `cap2ui5`; it is withdrawn from npm (0.2.0 never reached it), so the
-site names it only as the old name. What did NOT change name: the
+`@cap2ui5/samples@0.2.0` (peer `^0.4.0`). The plugin pins
+`@abap2ui5/node-runtime@1.146.0` **exactly**, the first runtime whose frontend
+does the approuter's `X-CSRF-Token` handshake and has `accelerate( )`.
+verify-refs reads that pin from `plugin/package.json`, not from the stand-in
+`runtime/package.json`. Up to 0.2.0 the plugin was the unscoped
+package `cap2ui5`. On npm that name is now only a deprecated placeholder,
+`cap2ui5@0.0.1-placeholder` (since 2026-09-30, cap2UI5/cap2UI5#97): no `main`,
+and a `cap2ui5` bin that prints where to go and exits 1. It installs nothing an
+app can use (0.2.0 never reached npm), so the site names it only as the old
+name. What did NOT change name: the
configuration `cds.requires.cap2ui5`, `cds add cap2ui5`, the bin `cap2ui5`
(`npx --no-install cap2ui5 abap2js`), the entity `cap2ui5.Drafts` and the
logger `cap2ui5`. The runtime package was renamed from
@@ -140,8 +146,8 @@ against the cap2UI5 repository.
be given a host-side registration (`defineExit`) instead. Boot the runtime
and check rather than porting a claim from abap2UI5's documentation.
- Write the translator as `npx --no-install cap2ui5 abap2js`, never a bare
- `npx cap2ui5`. The unscoped npm name `cap2ui5` was withdrawn and is
- anybody's now; a bare `npx cap2ui5` where the plugin is not installed
+ `npx cap2ui5`. The unscoped npm name `cap2ui5` holds only a placeholder;
+ a bare `npx cap2ui5` where the plugin is not installed
downloads and runs whatever npm has under it. `--no-install` runs the
project's own `@cap2ui5/cds-plugin` bin or fails. A page that shows the
command says it needs the plugin installed. Left as they are: `cds add
diff --git a/HANDOVER.md b/HANDOVER.md
index b5e9722..0c33914 100644
--- a/HANDOVER.md
+++ b/HANDOVER.md
@@ -29,6 +29,12 @@ The reasoning behind all of it is in [ROADMAP.md](ROADMAP.md) §§8–25 and in
## Open — the approuter and CSRF
+> **2026-09-30:** resolved in code. `@cap2ui5/cds-plugin` 0.4.0 pins runtime
+> 1.146.0, which carries `5a1bd70`; the deployment page now says the generated
+> catch-all route works, and keeps the extra route only for 0.3.x. What is left
+> is to run it once behind an approuter bound to XSUAA or IAS — it is read from
+> both sides' code, not measured. The text below is the state before.
+
`cds add approuter` generates a catch-all route with `"csrfProtection": true`,
and the frontend in runtime 1.145.0 sends no `X-CSRF-Token`, so behind that
route every roundtrip gets 403. The site documents the working setup — an
diff --git a/docs/.verify-refs-ignore b/docs/.verify-refs-ignore
index bf353cc..c04750e 100644
--- a/docs/.verify-refs-ignore
+++ b/docs/.verify-refs-ignore
@@ -17,6 +17,7 @@ my_first_app # the class the getting-started walkthrough has you writ
my_app_name # stands for "your class" in the ?app_start= URL on the navigation page
zcl_my_app # stands for "your app class": the name you gave defineApp in the why-cap2UI5 pitch, and the ABAP class on the views page — an ABAP app is registered by its transpiled class, not by defineApp
ClassName # literal placeholder in the URL-parameter description
+z2ui5_cl_smp_app_493 # abap2UI5's hello-world sample: a class in abap2UI5/samples and @cap2ui5/samples, not in abap2UI5's src
# --- apps of other repositories -------------------------------------------
# Registered with defineApp in their own repository, not in cap2UI5 - and apps,
diff --git a/docs/api/app-interface.md b/docs/api/app-interface.md
index 9d2976f..1801933 100644
--- a/docs/api/app-interface.md
+++ b/docs/api/app-interface.md
@@ -55,10 +55,27 @@ The wrapper awaits it either way.
### State is the fields
-Every field with an initial value becomes part of the model and survives the
-roundtrip. A field the plugin cannot type is left out and **named in a
-warning**, never silently dropped. Unlike ABAP there is no `PROTECTED
-SECTION`: every field is model.
+Every field with an initial value is kept in the draft and survives the
+roundtrip. It reaches the browser only once the app **binds** it —
+`_bind()`, `_bind_edit()`, `_bind_path()`, a component or a cell — and from
+then on it stays bound, as the framework does for an ABAP app. So a field no
+view shows is neither sent to the browser nor taken back from it; there is no
+`PROTECTED SECTION` to hide it, and none is needed. A field the plugin cannot
+type is left out and **named in a warning**, never silently dropped.
+
+Three names `defineApp` refuses, each with a message that says what to write
+instead:
+
+- **a camelCase field** — `isAdmin = false`. A field is an ABAP attribute, and
+ the runtime reads it by its lower-case name; write `is_admin`. Components of
+ a structure and the class's methods may be camelCase.
+- **a `#private` member** used in `main()` or a method it calls. They run on a
+ proxy of the instance, which a private name does not reach. Use a plain
+ field — it is not sent to the browser unless bound — or a module-level
+ function.
+- **an app name the runtime already has a class of** — the framework's own,
+ one of its apps, the plugin's. `defineApp("Z2UI5_CL_UTIL", …)` would replace
+ what every roundtrip runs.
Inside `main` and inside any method it calls, fields read and write as plain
values. A helper method that needs the client gets it as an ABAP app does —
diff --git a/docs/examples/selection-screen.md b/docs/examples/selection-screen.md
index b4a135d..0e70dca 100644
--- a/docs/examples/selection-screen.md
+++ b/docs/examples/selection-screen.md
@@ -18,8 +18,8 @@ const { SELECT } = cds.ql;
defineApp("ZCL_ORDERS", class {
customer = "";
- minTotal = 0;
- onlyOpen = false;
+ min_total = 0;
+ only_open = false;
rows = t.table({ ID: 0, customer: "", total: t.packed(11, 2), open: false });
hits = 0;
@@ -33,8 +33,8 @@ defineApp("ZCL_ORDERS", class {
`` +
`` +
` ` +
- ` ` +
- ` ` +
+ ` ` +
+ ` ` +
` ` +
` ` +
@@ -54,8 +54,8 @@ defineApp("ZCL_ORDERS", class {
if (client.check_on_event("GO")) {
const { Orders } = cds.entities("my.shop");
let q = SELECT.from(Orders).where`customer like ${"%" + this.customer + "%"}`;
- if (this.minTotal) q = q.and`total >= ${this.minTotal}`;
- if (this.onlyOpen) q = q.and`open = ${true}`;
+ if (this.min_total) q = q.and`total >= ${this.min_total}`;
+ if (this.only_open) q = q.and`open = ${true}`;
this.rows = await q;
this.hits = this.rows.length;
@@ -78,8 +78,8 @@ needs no `client.view_display()`. Render again only when the view's
| field | declared as | why |
|---|---|---|
| `customer` | `""` | `string` |
-| `minTotal` | `0` | integer. A decimal threshold would be `t.packed(11, 2)` |
-| `onlyOpen` | `false` | `abap_bool`; your code still sees `true`/`false` |
+| `min_total` | `0` | integer. A decimal threshold would be `t.packed(11, 2)` |
+| `only_open` | `false` | `abap_bool`; your code still sees `true`/`false` |
| `rows` | `t.table({…})` | an empty array carries no type |
A `CheckBox` binds `selected`, an `Input` binds `value` — ordinary UI5.
@@ -93,7 +93,7 @@ name containing a quote is a value and not a syntax error.
## Keeping the criteria
-They persist for free. `customer`, `minTotal` and `onlyOpen` are declared
+They persist for free. `customer`, `min_total` and `only_open` are declared
fields, so they are in the draft: come back to the app after a navigation and
the form is still filled in — including after a server restart.
diff --git a/docs/guide/data-binding.md b/docs/guide/data-binding.md
index 568aa56..39fed81 100644
--- a/docs/guide/data-binding.md
+++ b/docs/guide/data-binding.md
@@ -1,8 +1,11 @@
# Data Binding
-A field of your class is a bound model field. `client._bind("name")` gives you
-the binding path to put in the view; the browser sends the value back, and the
-framework applies it to the instance before your `main` runs.
+A field of your class becomes a model field when the app binds it.
+`client._bind("name")` gives you the binding path to put in the view; from
+then on the field is sent to the browser, the browser sends the value back,
+and the framework applies it to the instance before your `main` runs. A field
+the app never binds stays on the server — kept in the draft, never sent, never
+overwritten by what a browser posts.
```js
defineApp("ZCL_HELLO", class {
diff --git a/docs/guide/ecosystem.md b/docs/guide/ecosystem.md
index 8d99703..b12f82f 100644
--- a/docs/guide/ecosystem.md
+++ b/docs/guide/ecosystem.md
@@ -31,11 +31,12 @@ abap2UI5 builds itself.
| `@abap2ui5/node-runtime` | abap2UI5: upstream's ABAP, downported and transpiled over open-abap, with the UI5 frontend embedded in the page its GET answers with — backend and frontend from one commit |
| `@cap2ui5/samples` | abap2UI5's samples as cap2UI5 apps. Optional: added to a project, they run beside its own apps — see [Apps from a package](./project-structure#apps-from-a-package) |
-All three are on npm: `@cap2ui5/cds-plugin` 0.3.1 (Node ≥ 22, `@sap/cds` ≥ 9 as a peer),
-`@abap2ui5/node-runtime` 1.145.0 (Node ≥ 22), which the plugin pins exactly,
-and `@cap2ui5/samples` 0.1.0. `npm add @cap2ui5/cds-plugin` installs the first two — see the
+All three are on npm: `@cap2ui5/cds-plugin` 0.4.0 (Node ≥ 22, `@sap/cds` ≥ 9 as a peer),
+`@abap2ui5/node-runtime` 1.146.0 (Node ≥ 22), which the plugin pins exactly,
+and `@cap2ui5/samples` 0.2.0, which needs plugin 0.4. `npm add @cap2ui5/cds-plugin` installs the first two — see the
[Quickstart](./getting-started). Up to 0.2.0 the plugin was the unscoped
-package `cap2ui5`, which is withdrawn from npm.
+package `cap2ui5`. On npm that name is now only a deprecated placeholder that
+installs nothing usable; its `cap2ui5` command prints where to go instead.
## What the plugin does and does not own
diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md
index 3c47ac0..2f60312 100644
--- a/docs/guide/getting-started.md
+++ b/docs/guide/getting-started.md
@@ -72,8 +72,9 @@ Your own `server.js`, if you have one, is not touched. Nothing is generated
into your repository, and there are no frontend files to serve.
::: info Coming from the package `cap2ui5`
-Up to 0.2.0 the plugin was the unscoped package `cap2ui5`, which is withdrawn
-from npm. A project that has it swaps it:
+Up to 0.2.0 the plugin was the unscoped package `cap2ui5`. On npm that name is
+now only a deprecated placeholder that installs nothing usable. A project that
+has it swaps it:
```bash
npm rm cap2ui5 && npm add @cap2ui5/cds-plugin
@@ -298,8 +299,14 @@ npm add -D @cap2ui5/samples
cds watch
```
-The startup lines now list every sample beside `HELLO` and `BOOKS`, each under
-its ABAP class name. As a devDependency the samples are there in development
+The startup lines gain one line for the package, below `HELLO` and `BOOKS`:
+
+```
+[cap2ui5] - @cap2ui5/samples 71 apps - listed on CAP's start page, http://localhost:4004/
+```
+
+CAP's start page lists every sample under its ABAP class name, e.g.
+`Z2UI5_CL_SMP_APP_493`, abap2UI5's hello world. As a devDependency the samples are there in development
only; a production start leaves them out. How a package brings apps is in
[Project Structure](./project-structure#apps-from-a-package), the list of
samples in the [cap2UI5/samples](https://github.com/cap2UI5/samples)
diff --git a/docs/guide/migration-from-abap2ui5.md b/docs/guide/migration-from-abap2ui5.md
index 293679d..54fe2ff 100644
--- a/docs/guide/migration-from-abap2ui5.md
+++ b/docs/guide/migration-from-abap2ui5.md
@@ -104,8 +104,12 @@ fields can be written — `app.backend_event = "…"`, then
**`client.nav_app_call( app, fields )`** presets the called app's fields —
what an ABAP app does between `NEW` and `nav_app_call( )`.
-**Every field is model.** There is no `PROTECTED SECTION`: every field with an
-initial value is part of the model. A helper method that needs the client gets
+**A bound field is model.** Every field with an initial value is kept in the
+draft, and one the app binds is sent to the browser and written back — as
+abap2UI5 does for an ABAP app's attributes. There is no `PROTECTED SECTION`,
+and none is needed to keep a field out of the browser: don't bind it. Fields
+are named in snake_case, as ABAP attributes are — `defineApp` refuses a
+camelCase one. A helper method that needs the client gets
it as in ABAP, `this.client = client` in `main()`, without declaring it as a
field.
diff --git a/docs/guide/persistence.md b/docs/guide/persistence.md
index 1898803..5ce988d 100644
--- a/docs/guide/persistence.md
+++ b/docs/guide/persistence.md
@@ -39,7 +39,8 @@ defineApp("ZCL_ORDER", class {
});
```
-All three survive. Structures and tables nest as deeply as you like — the model
+All three survive — every declared field is kept in the draft, bound or not;
+only what the app binds is also sent to the browser. Structures and tables nest as deeply as you like — the model
carries `ORDER.CUSTOMER.CITY` and `ORDER.LINES[].PRICE`, decimals included, and
the app reads the whole tree back as plain values on a later roundtrip.
diff --git a/docs/guide/project-structure.md b/docs/guide/project-structure.md
index 1eefc8d..1bdd7c1 100644
--- a/docs/guide/project-structure.md
+++ b/docs/guide/project-structure.md
@@ -86,7 +86,7 @@ same one:
```json
{
"cap2ui5": { "apps": "srv/apps" },
- "peerDependencies": { "@cap2ui5/cds-plugin": "^0.3.0" }
+ "peerDependencies": { "@cap2ui5/cds-plugin": "^0.4.0" }
}
```
diff --git a/docs/guide/roadmap.md b/docs/guide/roadmap.md
index 81edb73..4768471 100644
--- a/docs/guide/roadmap.md
+++ b/docs/guide/roadmap.md
@@ -22,20 +22,21 @@ exported `z2ui5_cl_ui5_view_builder` and shipped TypeScript declarations;
0.3.0 added `npx --no-install cap2ui5 abap2js`, which translates an abap2UI5
app class into a cap2UI5 app line for line, and apps that come from a package —
[`@cap2ui5/samples`](https://github.com/cap2UI5/samples) is 71 of abap2UI5's
-samples that way. The details are in the plugin's
+samples that way. 0.4.0 sends the browser only the fields an app binds, gzips
+the page and the roundtrips, and hosts runtime 1.146.0, whose frontend does the
+approuter's CSRF token handshake and whose accelerations make a table of
+thousands of rows usable. The details are in the plugin's
[CHANGELOG](https://github.com/cap2UI5/cap2UI5/blob/main/plugin/CHANGELOG.md).
## Known limits today
-### Behind an approuter, the roundtrip path needs its own route
+### Not yet run on BTP
-The frontend in runtime `1.145.0` sends no `X-CSRF-Token`, and the route
-`cds add approuter` generates demands one on every POST — so every roundtrip
-is refused with `403` until the roundtrip path gets a route of its own with
-`"csrfProtection": false`. The route, and why it is safe, are on
-[Deployment](../reference/deployment#the-approuter-needs-one-extra-route-today).
-abap2UI5/abap2UI5#2802 teaches the frontend the token handshake; it is merged,
-but no abap2UI5 release carries it yet.
+Behind an approuter, the route `cds add approuter` generates should work as it
+is since 0.4.0: the frontend in runtime 1.146.0 does the `X-CSRF-Token`
+handshake the approuter asks for. That is read from both sides' code, not yet
+run end to end against XSUAA or IAS — see
+[Deployment](../reference/deployment#the-approuter-and-its-csrf-token).
### The apps are JavaScript, and so are the errors
@@ -76,10 +77,6 @@ background pages may still carry the port's mechanics; if a page contradicts
## What's next
-**Drop the approuter exception.** Once abap2UI5/abap2UI5#2802 is in an
-upstream release, pin the plugin to it, and the route CAP generates works
-unchanged.
-
**Teach `abap2js` more ABAP.** Some of its refusals say "not supported yet".
**Finish this documentation.**
diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md
index c0003df..48d16a6 100644
--- a/docs/guide/troubleshooting.md
+++ b/docs/guide/troubleshooting.md
@@ -183,10 +183,11 @@ refused without the payload being buffered. To open the route deliberately, set
The page loads, the first click fails with `403`, and the response carries
`x-csrf-token: Required`. That is the approuter, not the plugin: the route
-`cds add approuter` generates has `"csrfProtection": true`, and the abap2UI5
-frontend in the pinned runtime sends no CSRF token. Give the roundtrip path a
-route of its own with `"csrfProtection": false` — the exact route, and why it
-is safe, are in [Deployment](../reference/deployment#the-approuter-needs-one-extra-route-today).
+`cds add approuter` generates has `"csrfProtection": true`. Since plugin 0.4.0
+the frontend fetches and sends the token itself, so this means an older plugin:
+the frontend in the runtime 0.3.x pins sends none. Upgrade to
+0.4.0, or give the roundtrip path a route of its own — see
+[Deployment](../reference/deployment#the-approuter-and-its-csrf-token).
## Two users see each other's state
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index ef3b9fd..cfd46af 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -29,6 +29,8 @@ These ship in the plugin's own `package.json` and apply until you override one:
| `roles` | who may call: a role, or a list of roles any one of which lets the user in, as with CAP's `@requires`. `any` or `null` lets anonymous callers in — read the box below first |
| `routes` | the paths the roundtrip answers on. Both defaults exist so that a frontend or a bookmark written for either name works. A GET on a route answers with the page that embeds the whole UI5 frontend — there is no separate static route to configure |
| `body_parser.limit` | the largest roundtrip body, a larger one gets 413. No default of its own: CAP's `cds.server.body_parser.limit` applies, else `10mb`. A roundtrip carries the app's whole model, so a table of a few thousand rows is an ordinary request |
+| `compression` | `true`: the page and every roundtrip of 1 kB or more are gzipped where the browser accepts gzip — the page, which carries the whole UI5 frontend, goes out as 83 kB instead of 358 kB. Nothing is compressed twice behind an approuter or ingress that compresses too; `false` leaves the work to it |
+| `accelerate` | `true`: the plugin calls the runtime's `accelerate( )` when the server starts, where the runtime has one (1.146.0 does), and logs `runtime accelerations active`. `false` runs the runtime's own code — see [Performance](#performance) |
`"cap2ui5": false` under `cds.requires` switches the plugin off: no route, and
no `cap2ui5.Drafts` table in the model.
@@ -101,8 +103,8 @@ run behind it under `xsuaa` and `ias` and not only under `mocked`.
## The runtime
-`@cap2ui5/cds-plugin` depends on `@abap2ui5/node-runtime` **pinned exactly** — `1.145.0`
-for 0.3.1 — so `npm add @cap2ui5/cds-plugin` already gives you one known
+`@cap2ui5/cds-plugin` depends on `@abap2ui5/node-runtime` **pinned exactly** — `1.146.0`
+for 0.4.0 — so `npm add @cap2ui5/cds-plugin` already gives you one known
runtime release. There is nothing to add to your own `package.json`.
The runtime resolves as the plugin's own dependency, at the pinned version.
@@ -110,13 +112,32 @@ To load another release, use npm `overrides` in your `package.json`. The log
names what was loaded at startup:
```
-[cap2ui5] - @abap2ui5/node-runtime 1.145.0 from …/node_modules/@abap2ui5/node-runtime
+[cap2ui5] - @abap2ui5/node-runtime 1.146.0 from …/node_modules/@abap2ui5/node-runtime
+[cap2ui5] - runtime accelerations active
```
Backend, UI5 frontend and wire protocol version come from that one package,
which is what makes a frontend/backend mismatch impossible. See
[HTTP Protocol](./protocol).
+## Performance
+
+A roundtrip carries the app's model, restored from the draft, run and answered
+whole, so its cost grows with the model. Measured by the plugin on one editable
+table (the plugin's README has the table):
+
+- **The runtime's accelerations.** Without them, a table of n rows costs time
+ in n² — not in abap2UI5's ABAP but in two places of `@abaplint/runtime` it
+ runs on. With them (runtime 1.146.0, `accelerate` on), 2000 rows start in
+ 1.6 s instead of 19.6 s, and an edited cell answers in 2.9 s instead of
+ 43.5 s.
+- **Node 24 is recommended.** CAP keeps `cds.context` in an
+ `AsyncLocalStorage`, which on Node 22 costs the transpiled framework about
+ as much again as its own work; Node 24 keeps it nearly free. On Node 22.7
+ and later, `NODE_OPTIONS=--experimental-async-context-frame` does the same.
+- **`NODE_COMPILE_CACHE`** set to a directory saves part of the runtime's
+ import on every start — worth it for `cds watch`.
+
## What the plugin does not configure
Everything the **framework** decides about a response — the UI5 bootstrap URL,
diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md
index 7846f9e..153e37a 100644
--- a/docs/reference/deployment.md
+++ b/docs/reference/deployment.md
@@ -37,12 +37,12 @@ then `cds deploy` once. `cap2ui5.Drafts` is created along with your own tables.
| **The entity** | `cap2ui5.Drafts` deploys through your normal `db` module, HDI container included. Nothing special |
| **Authentication** | whatever `cds.requires.auth` is — the route runs behind CAP's own chain. Verified for `jwt`, `xsuaa` and `ias`, not only for the development kinds |
| **Scaling** | app state is in the database, not in memory, so a second instance is a second instance. No sticky sessions, no shared cache |
-| **The runtime** | `@cap2ui5/cds-plugin` pins `@abap2ui5/node-runtime` exactly (`1.145.0` for 0.3.1). Commit your `package-lock.json` and a redeploy installs the same release |
+| **The runtime** | `@cap2ui5/cds-plugin` pins `@abap2ui5/node-runtime` exactly (`1.146.0` for 0.4.0). Commit your `package-lock.json` and a redeploy installs the same release |
## Pin the plugin
```json
-{ "dependencies": { "@cap2ui5/cds-plugin": "^0.3.1" } }
+{ "dependencies": { "@cap2ui5/cds-plugin": "^0.4.0" } }
```
`@cap2ui5/cds-plugin` is on npm, and it depends on one exact `@abap2ui5/node-runtime`
@@ -83,7 +83,7 @@ resources:
There is **no HTML5 module for the frontend** and no app-repo push, because
the frontend is embedded in the page the service itself answers with.
-### The approuter needs one extra route today
+### The approuter and its CSRF token
`cds add xsuaa,approuter` (cds-dk 10.1) writes `.deploy/app-router/xs-app.json`
with a single catch-all route:
@@ -96,16 +96,25 @@ with a single catch-all route:
}
```
-With that route **every roundtrip is refused**. `@sap/approuter` requires an
-`x-csrf-token` header on every request other than GET and HEAD to an
-authenticated route whose `csrfProtection` is not `false`, and answers
-`403` with `x-csrf-token: Required` otherwise (read in approuter 23.0.0,
-`lib/middleware/xsrf-token-handler.js`). The abap2UI5 frontend up to and
-including runtime `1.145.0` — the release `@cap2ui5/cds-plugin` 0.3.1 pins — sends no
-such token. The first page loads, because it is a GET; the first click fails.
-
-What works today is a route for the roundtrip path **in front of** the
-catch-all, with the approuter's token check off:
+`@sap/approuter` requires an `x-csrf-token` header on every request other than
+GET and HEAD to an authenticated route whose `csrfProtection` is not `false`,
+and answers `403` with `x-csrf-token: Required` otherwise. It hands out the
+token on a GET or HEAD that carries `x-csrf-token: Fetch`, bound to the session
+(approuter 23.0.0, `lib/middleware/xsrf-token-handler.js`).
+
+Since `@cap2ui5/cds-plugin` 0.4.0 that route works as generated. The frontend
+of runtime `1.146.0`, which it pins, answers a `403` with
+`x-csrf-token: Required` by fetching the token with a HEAD request and sending
+the roundtrip again with it — the handshake
+[abap2UI5/abap2UI5#2802](https://github.com/abap2UI5/abap2UI5/pull/2802) added.
+That is read from both sides' code; it has not yet been run end to end
+against an approuter bound to XSUAA or IAS.
+
+::: details With plugin 0.3.x: one extra route
+The frontend in the runtime 0.3.x pins sends no token, so behind
+the generated route the page loads and the first click fails. Upgrade, or put
+a route for the roundtrip path in front of the catch-all with the approuter's
+check off:
```json
{
@@ -116,28 +125,17 @@ catch-all, with the approuter's token check off:
}
```
-Add the same route for `rest/root/z2ui5` if your frontend or bookmarks use that
-path, and adjust both if you changed `cds.requires.cap2ui5.routes`.
-
-This is not an open door. Authentication still applies — the route sets no
-`authenticationType`, so the approuter's default applies, and the plugin's own
-guard runs behind it either way — and abap2UI5
-refuses a cross-origin POST itself: it compares the request's `Origin` (or
-`Referer`) against the host, or against the `X-Forwarded-Host` the approuter
-sets, and answers `403` on a mismatch. Measured against approuter 23.0.0 run
-locally without xsuaa: the roundtrips pass, and a POST with a foreign `Origin`
-gets `403`. That protection is the framework's CSRF gate, so leave
+That is not an open door: authentication still applies, and abap2UI5 refuses a
+cross-origin POST itself — it compares `Origin` (or `Referer`) with the host,
+or with the `X-Forwarded-Host` the approuter sets, and answers `403` on a
+mismatch (measured against approuter 23.0.0 run locally without xsuaa). Leave
`check_csrf_active` on in your [user exit](../guide/user-exit#csrf) when you
-use this route.
-
-::: info Pending upstream: abap2UI5/abap2UI5#2802
-That pull request teaches the frontend the standard `X-CSRF-Token`
-fetch-and-send handshake. It was merged on 2026-09-27, after the release
-the plugin pins, and no abap2UI5 release carries it yet. Once the plugin pins a
-runtime release that does, the extra route can go and the generated catch-all works as
-it is. Until then, keep the route above.
+use it.
:::
+Either way the framework's own CSRF gate stays in front of the apps: leave
+`check_csrf_active` on in your [user exit](../guide/user-exit#csrf).
+
## Scale-to-zero and restarts
Both are fine, and tested rather than argued: a process can be **SIGKILLed
diff --git a/scripts/verify-refs.mjs b/scripts/verify-refs.mjs
index 954139e..500b2c8 100644
--- a/scripts/verify-refs.mjs
+++ b/scripts/verify-refs.mjs
@@ -55,13 +55,13 @@
* package actually exports, and a "@cap2ui5/cds-plugin/" specifier
* lands on a file that exists. Two dead packages are reported by name,
* in either form: "abap2UI5/…", the port's package, and "cap2ui5", the
- * plugin's name up to 0.2.0, withdrawn from npm.
+ * plugin's name up to 0.2.0, now a deprecated placeholder on npm.
* 5. every plugin option named as `cds.requires.cap2ui5.` is a key the plugin
* really defines, every three-part release number (1.x.y) is the runtime
* release the checkout pins or an allowlisted historical number, and a
* `"@cap2ui5/cds-plugin": "^x.y.z"` a page tells a reader to write is a
* range the plugin's own version actually falls in (and a dependency on
- * the withdrawn `"cap2ui5"` is reported whatever its range).
+ * the deprecated `"cap2ui5"` is reported whatever its range).
*
* Check 4 exists because the first three did not see the largest defect this
* site ever had. Fenced blocks were skipped wholesale as "examples, not
@@ -200,10 +200,17 @@ if (haveUpstream) {
* .verify-refs-ignore with a reason: historical upstream releases stay true
* whatever the pin says, and UI5 numbers are not framework releases at all.
* Two-part floors like 1.71 are UI5 talk and deliberately not matched. */
-const VERSION_SOURCE = "runtime/package.json";
+/* Read from the PLUGIN's dependency, not from runtime/package.json: that is the
+ * repository's stand-in, whose committed version only moves when someone
+ * assembles and commits it - it still said 1.145.0 after 0.4.0 pinned 1.146.0,
+ * and every stale 1.145.0 on the site passed. What a reader installs is the
+ * exact version plugin/package.json names. */
+const VERSION_SOURCE = "plugin/package.json";
const PINNED_RELEASE = (() => {
try {
- return JSON.parse(fs.readFileSync(path.join(APP, VERSION_SOURCE), "utf8")).version ?? null;
+ const spec = JSON.parse(fs.readFileSync(path.join(APP, VERSION_SOURCE), "utf8"))
+ .dependencies?.["@abap2ui5/node-runtime"];
+ return /^\d+\.\d+\.\d+$/.test(spec ?? "") ? spec : null;
} catch { return null; }
})();
@@ -221,8 +228,9 @@ const PLUGIN_VERSION = (() => {
} catch { return null; }
})();
const CAP2UI5_DEP_RE = /"@cap2ui5\/cds-plugin"\s*:\s*"[~^]?(\d+\.\d+)\.[\dx]+"/g;
-// The unscoped package `cap2ui5` was withdrawn from npm with 0.3.0 - a
-// dependency on it is a line a reader cannot install, whatever its range.
+// The unscoped package `cap2ui5` is, since 0.3.0, only a deprecated placeholder
+// on npm - a dependency on it installs nothing a reader can use, whatever its
+// range.
const WITHDRAWN_DEP_RE = /"cap2ui5"\s*:\s*"[~^]?\d/g;
const minor = (v) => String(v).split(".").slice(0, 2).join(".");
@@ -268,7 +276,7 @@ const RELEASE_RE = /\b1\.\d{2,3}\.\d+\b/g;
const CLASS_RE = /`(z2ui5_(?:cl|if|cx)_[a-z0-9_]+)(?![a-z0-9_])/gi;
// require("@cap2ui5/cds-plugin"), require("@cap2ui5/cds-plugin/lib/…"), and two
// dead packages: the port's `abap2UI5` and the plugin's own old name `cap2ui5`,
-// withdrawn from npm with 0.3.0.
+// a deprecated placeholder on npm since 0.3.0.
const PKG = "@cap2ui5/cds-plugin";
const REQUIRE_RE = /require\(\s*["'`](@cap2ui5\/cds-plugin|cap2ui5|abap2UI5)(?:\/([^"'`]+))?["'`]\s*\)/gi;
// `const { defineApp, t } = require("@cap2ui5/cds-plugin")` — the names, not just the path
@@ -326,7 +334,7 @@ for (const file of markdownFiles(DOCS)) {
}
for (const m of line.matchAll(WITHDRAWN_DEP_RE)) {
- add(file, n, `tells a reader to depend on "cap2ui5", which was withdrawn from npm `
+ add(file, n, `tells a reader to depend on "cap2ui5", which is only a deprecated placeholder on npm `
+ `- the package is "${PKG}"`);
}
@@ -335,7 +343,7 @@ for (const file of markdownFiles(DOCS)) {
for (const m of line.matchAll(RELEASE_RE)) {
if (m[0] === PINNED_RELEASE || IGNORE.has(m[0])) continue;
add(file, n, `names release ${m[0]}, but the checkout pins ${PINNED_RELEASE} `
- + `(version in ${VERSION_SOURCE}) - update the prose, or add the number `
+ + `(the @abap2ui5/node-runtime dependency in ${VERSION_SOURCE}) - update the prose, or add the number `
+ `to .verify-refs-ignore with the reason it stays`);
}
}
@@ -362,7 +370,7 @@ for (const file of markdownFiles(DOCS)) {
continue;
}
if (pkg.toLowerCase() === "cap2ui5") {
- add(file, n, `${how} "${spec}" is the plugin's OLD name, withdrawn from npm - `
+ add(file, n, `${how} "${spec}" is the plugin's OLD name, only a deprecated placeholder on npm - `
+ `the package is "${PKG}"`);
continue;
}