@zerotal/arch 1.13.3 → 1.13.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/changelog.md CHANGED
@@ -27,6 +27,85 @@ the section for every version you cross and apply its migration notes, not only
27
27
  majors. [Releases and versioning](/docs/support-policy#releases-and-versioning) explains
28
28
  when that carve-out ends.
29
29
 
30
+ ## 1.13.5 — 2026-09-01
31
+
32
+ **`inertia.ssr: true` server-renders now.** One config line, every page, no controller
33
+ changes — which is what the option is named for and what it did not do.
34
+
35
+ ### Fixed
36
+
37
+ - **The `ssr` flag rendered nothing.** It registered `POST /__ssr` and nothing in the
38
+ request path consulted it. So an app that set `ssr: true` and read
39
+ [the SSR guide](/docs/inertia/ssr) — which stated that the server renders the component
40
+ into the template — got exactly the empty root it had before, and the documentation was
41
+ the reason nobody suspected otherwise.
42
+
43
+ ```ts
44
+ // config/inertia.ts — this is now the whole of it
45
+ export default InertiaConfig({ ssr: true });
46
+ ```
47
+
48
+ `Inertia.render()` renders the component into the root, injects the page's `<Head>` into
49
+ the served `<head>`, and marks the root `data-server-rendered`. The scaffolded `app.tsx`
50
+ already hydrated on that attribute, so the client half needed nothing: turning SSR on is
51
+ one line, and there is no second step.
52
+
53
+ Server rendering was previously reachable only by rewriting each route to
54
+ `Inertia.stream()`, one call site at a time. Two teams did that. **They can go back to
55
+ `render()`** — and should, unless they wanted the streaming.
56
+
57
+ A component that fails to render falls back to the client-rendered document with a
58
+ warning rather than failing the route. The page still works in a browser, and taking a
59
+ route down because an _optimisation_ failed would make `ssr: true` a liability rather
60
+ than an improvement.
61
+
62
+ - **Streaming and SSR are separated in the docs.** `inertiaStream()` is not how you turn
63
+ server rendering on; it decides whether the bytes are **buffered or streamed**. Both
64
+ render the component and both hydrate. Conflating them is what made a per-route rewrite
65
+ look like the supported answer, so the comparison table and the "what a crawler sees"
66
+ remedies now lead with the config flag.
67
+
68
+ `POST /__ssr` is unchanged and documented for what it is: the contract for an
69
+ **external** renderer, not the in-process switch.
70
+
71
+ ## 1.13.4 — 2026-09-01
72
+
73
+ **Take this one if you are on 1.13.3.** The site gate shipped a staff bypass that failed
74
+ open, and a type error that landed on apps not using it. Both found by a team upgrading,
75
+ within a day.
76
+
77
+ ### Fixed
78
+
79
+ - **The gate's staff bypass was a denylist, and let the public in.** 1.13.3 admitted any
80
+ authenticated user whose role was not literally `"customer"`. In an app whose roles are
81
+ `user` and `admin` — which is most of them — that is every signed-in visitor, so a
82
+ private preview showed the site to anyone with an account. A gate that fails open is
83
+ worse than no gate, because it reports success while doing nothing.
84
+
85
+ It is an allowlist now: `gate.staffRoles`, defaulting to `["admin"]`. An app whose staff
86
+ role is named something else gets no bypass and notices, which is the safe direction to
87
+ be wrong in. The token path is unchanged.
88
+
89
+ Worth naming the mistake, because this release cycle already contained its lesson: the
90
+ 1.11.0 notes describe an app that wrote its own "is this error permanent" check as a
91
+ denylist and found it was a latent outage, and the fix was an allowlist. The same shape
92
+ went into the gate three releases later.
93
+
94
+ - **The same line broke `tsc` for apps that do not use the gate.** `role !== "customer"`
95
+ is a type error when an app's role union has no such member (`TS2367`), and the
96
+ framework ships TypeScript source, so the error arrived on a feature the app never
97
+ touched — failing its build. The role is read as `string` now, because the framework
98
+ cannot know an app's role names and must not narrow to them.
99
+
100
+ ### Documented
101
+
102
+ - **A tilde still crosses a patch, and under this scheme a patch carries features.**
103
+ `~1.13.2` is `>=1.13.2 <1.14.0`, so it takes 1.13.3 without asking — which is exactly
104
+ how 1.13.3 reached the app that found the bugs above. That is the right default for most
105
+ apps, and it is weaker protection than the same range gives under strict semver, where a
106
+ patch is only ever a bug fix. [The upgrade guide](/docs/upgrade#versioning) now says so,
107
+ and says to pin the exact version when you need it to hold.
108
+
30
109
  ## 1.13.3 — 2026-08-31
31
110
 
32
111
  Two things an app cannot see about itself, from two field reports. Both are the same
@@ -9,22 +9,7 @@ By default Inertia renders the first page on the client. Server-side rendering (
9
9
  renders the initial HTML on the server instead — better Time-to-First-Byte and
10
10
  crawlable content — while subsequent navigations keep using the fast JSON path.
11
11
 
12
- Zerotal offers two approaches: the **`/__ssr` endpoint** (standard Inertia SSR) and
13
- **streaming SSR** via `inertiaStream()`.
14
-
15
- ## Which should I use?
16
-
17
- - **Endpoint SSR** (`ssr: true`) — the standard Inertia SSR contract. Turn it on
18
- globally and the Inertia client renders the first page through `POST /__ssr`. Use
19
- this when you want crawlable, server-rendered HTML across the whole app with no
20
- per-controller change.
21
- - **Streaming SSR** (`inertiaStream()`) — swap `inertia()` for `inertiaStream()` in
22
- the controllers whose initial document you want streamed for the fastest TTFB. Use
23
- it selectively on heavy landing pages; everything else stays on `inertia()`.
24
-
25
- ## Endpoint SSR
26
-
27
- Enable the SSR endpoint in config:
12
+ Turn `ssr` on and every first page load is server-rendered. That is the whole of it:
28
13
 
29
14
  ```ts
30
15
  // config/inertia.ts
@@ -34,21 +19,70 @@ import { env } from "zerotal";
34
19
  export default InertiaConfig({
35
20
  htmlTemplate: "./resources/app.html",
36
21
  version: env("ASSET_VERSION", "1"),
37
- ssr: true, // registers POST /__ssr — requires a server renderer
22
+ ssr: true,
38
23
  });
39
24
  ```
40
25
 
41
- When `ssr: true`, `InertiaProvider` registers `POST /__ssr`, which accepts
42
- `{ component, props, url }` and returns `{ body, head }` the same contract as the
43
- Inertia Node SSR server. On a first-page load the server renders the component into
44
- the template instead of shipping an empty root `<div>`; subsequent navigations use
45
- the normal JSON path. Pages render with the framework they're authored in: React
46
- `.tsx` via `react-dom/server`, or Vue `.vue` via `@inertiajs/vue3` +
47
- `vue/server-renderer` install the server renderer for the framework(s) your app uses.
26
+ No controller changes. `Inertia.render()` renders the component into the root, injects
27
+ the page's `<Head>` tags into the document head, and marks the root
28
+ `data-server-rendered` so the client **hydrates** that markup instead of throwing it
29
+ away and painting again. The scaffolded `app.tsx` already does the hydrating half:
30
+
31
+ ```tsx fragment
32
+ setup({ el, App, props }) {
33
+ const app = <App {...props} />;
34
+ if (el.hasAttribute("data-server-rendered")) {
35
+ hydrateRoot(el, app);
36
+ } else {
37
+ createRoot(el).render(app);
38
+ }
39
+ }
40
+ ```
41
+
42
+ Subsequent navigations are unaffected — an `X-Inertia` XHR gets the page object as JSON
43
+ either way, because server rendering is about the _first_ arrival.
44
+
45
+ > **Before 1.13.4, `ssr: true` did not do this.** It registered `POST /__ssr` and nothing
46
+ > in the request path consulted it, so an app that set the flag and read this page got
47
+ > exactly the empty root it had before, and server rendering was reachable only by
48
+ > rewriting each route to `inertiaStream()`. If you worked around that with a per-route
49
+ > switch, you can delete it.
48
50
 
49
- ## Streaming SSR
51
+ Pages render with the framework they are authored in: React `.tsx` via
52
+ `react-dom/server`, Vue `.vue` via `@inertiajs/vue3` + `vue/server-renderer`. Install the
53
+ server renderer for the framework your app uses.
50
54
 
51
- `inertiaStream()` is a drop-in async alternative to `inertia()` that uses React 18's
55
+ **A page that fails to server-render still works.** The failure is logged and the
56
+ client-rendered document is served instead, because taking a route down because an
57
+ _optimisation_ failed would make `ssr: true` a liability rather than an improvement.
58
+
59
+ ## What `POST /__ssr` is for
60
+
61
+ `ssr: true` also registers it. It accepts `{ component, props, url }` and returns
62
+ `{ body, head }` — the same contract as the Inertia Node SSR server — and it exists for
63
+ an **external** caller: a separate renderer process, or a deployment that renders
64
+ somewhere other than the web process.
65
+
66
+ You do not need it for the switch above, which renders in-process. It is throttled and
67
+ loopback-gated; see `ssrSecret` for reaching it from another host.
68
+
69
+ ## Streaming SSR — a different question
70
+
71
+ `inertiaStream()` is **not** how you turn SSR on — that is the config flag. Streaming is
72
+ about **time to first byte**: the document goes out in pieces as the component renders,
73
+ rather than being buffered and sent whole.
74
+
75
+ That is a trade, which is why it stays a per-route choice rather than an app-wide one. A
76
+ streamed response starts arriving sooner and finishes no earlier, and on a page that is
77
+ mostly shell the buffering costs nothing worth reclaiming. Reach for it on a heavy
78
+ landing page; leave everything else on `Inertia.render()`.
79
+
80
+ Both render the component and both mark the root for hydration. The only difference is
81
+ whether the bytes are streamed.
82
+
83
+ ### How it streams
84
+
85
+ It uses React 18's
52
86
  `renderToReadableStream` to improve TTFB. Instead of buffering the whole render, it
53
87
  streams the React output between the template's HTML prefix and suffix:
54
88
 
@@ -82,14 +116,17 @@ export class PostController {
82
116
 
83
117
  ### inertia vs. inertiaStream
84
118
 
85
- | Criterion | `inertia()` | `inertiaStream()` |
86
- | -------------- | ------------------------ | ---------------------------------- |
87
- | Return type | `Promise<void>` | `Promise<void>` |
88
- | Rendering | None empty root + JSON | Streaming `renderToReadableStream` |
89
- | Response body | Fully buffered string | Streaming `ReadableStream` |
90
- | TTFB | Immediate | After the shell is ready |
91
- | Page `<Head>` | Client only | Collected into the served `<head>` |
92
- | XHR navigation | JSON (the normal path) | JSON — the same page object |
119
+ | Criterion | `inertia()` | `inertiaStream()` |
120
+ | -------------- | --------------------------------------- | ---------------------------------- |
121
+ | Return type | `Promise<void>` | `Promise<void>` |
122
+ | Rendering | Server-rendered when `ssr: true` | Always server-rendered |
123
+ | Response body | Fully buffered string | Streaming `ReadableStream` |
124
+ | TTFB | After the render | As the component renders |
125
+ | Page `<Head>` | In the served `<head>` when `ssr: true` | Collected into the served `<head>` |
126
+ | XHR navigation | JSON (the normal path) | JSON — the same page object |
127
+
128
+ With `ssr: false` — the default — `inertia()` serves an empty root and the page object,
129
+ and the browser does all the rendering.
93
130
 
94
131
  **Both implement the whole protocol.** An `X-Inertia: true` request gets the page
95
132
  object as JSON from either one; the streaming half applies to the first arrival,
@@ -152,8 +189,9 @@ Two things it does not do:
152
189
 
153
190
  ## What a crawler sees
154
191
 
155
- `inertia()` — the default — **does not server-render the component at all.** Its
156
- response body is the template with an empty root and the page object beside it:
192
+ **With `ssr: false` — the default — `inertia()` does not server-render the component at
193
+ all.** Its response body is the template with an empty root and the page object beside
194
+ it:
157
195
 
158
196
  ```html
159
197
  <body>
@@ -179,10 +217,11 @@ readers of your site run JavaScript and which do not:
179
217
  So the link preview a page produces is decided entirely by its `<head>` — which is
180
218
  the template's, identically, on every page, unless you do one of these:
181
219
 
182
- 1. **Switch the page to `inertiaStream()`.** The component is rendered, `<Head>` is
183
- collected, and the served `<head>` is the page's own. This is the smallest change
184
- and the one to reach for on pages that get shared.
185
- 2. **Turn on endpoint SSR** (`ssr: true`) for the whole app.
220
+ 1. **Turn on `ssr: true`.** One line, every page: the component is rendered, `<Head>` is
221
+ collected, and the served `<head>` is the page's own. This is the change to reach for
222
+ unless you have a reason not to.
223
+ 2. **Switch a single page to `inertiaStream()`**, if you want that and streaming on one
224
+ route without turning rendering on everywhere.
186
225
  3. **Set the tags in middleware**, if the metadata is server-side data the component
187
226
  does not otherwise need.
188
227
 
package/docs/site-gate.md CHANGED
@@ -64,9 +64,20 @@ on every outbound link, into analytics, into screenshots, and into the message w
64
64
  somebody shares "the page I was looking at". Stripping it on first use leaves the
65
65
  secret in a cookie and nowhere else.
66
66
 
67
- **A signed-in staff account.** A request whose authenticated user has any role other
68
- than `customer` is admitted without a token, so an app that already has staff accounts
69
- does not need a second secret for the same people.
67
+ **A signed-in staff account.** A request whose authenticated user holds a role on the
68
+ allowlist is admitted without a token, so an app that already has staff accounts does not
69
+ need a second secret for the same people. It defaults to `["admin"]`:
70
+
71
+ ```ts
72
+ // config/gate.ts
73
+ export default { staffRoles: ["admin", "editor"] };
74
+ ```
75
+
76
+ An **allowlist**, and it matters. 1.13.3 shipped the inverse — "anyone whose role is not
77
+ `customer`" — which in an app whose roles are `user` and `admin` admitted every signed-in
78
+ visitor and let the public straight through. A gate that fails open is worse than no gate,
79
+ because it reports success while doing nothing. An app whose staff role is named something
80
+ this does not list gets no bypass and notices, which is the safe direction to be wrong in.
70
81
 
71
82
  The cookie lasts seven days. A preview cookie with no lifetime means an ex-tester keeps
72
83
  access to a site that has since gone live with real customer data.
package/docs/upgrade.md CHANGED
@@ -33,6 +33,13 @@ something, and puts the work where it is useful: reading the notes for each mino
33
33
  > install 1.11.0, and its breaking change, without asking. Read the notes for every
34
34
  > minor you cross, or pin with a tilde (`~1.10.0`) and cross them deliberately.
35
35
 
36
+ > **Warning** — **a tilde still crosses a patch**, and under this scheme a patch carries
37
+ > features. `~1.13.2` means `>=1.13.2 <1.14.0`, so it takes 1.13.3 without asking. That is
38
+ > the right default for most apps and it is weaker protection than the same range gives
39
+ > under strict semver, where a patch is only ever a bug fix. If you need the version to
40
+ > hold exactly where you put it — a release you have certified, a machine you cannot
41
+ > re-test quickly — pin the exact version with no range operator at all.
42
+
36
43
  > **Warning** — always upgrade the `@zerotal/*` packages together. Mixing versions across core, ORM, and feature packages leads to type and runtime mismatches.
37
44
 
38
45
  ## Upgrade steps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/arch",
3
- "version": "1.13.3",
3
+ "version": "1.13.5",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -35,11 +35,11 @@
35
35
  "typecheck": "tsc --noEmit"
36
36
  },
37
37
  "dependencies": {
38
- "@zerotal/core": "1.13.3"
38
+ "@zerotal/core": "1.13.5"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0",
42
- "@zerotal/orm": "1.13.3"
42
+ "@zerotal/orm": "1.13.5"
43
43
  },
44
44
  "description": "The Zerotal agent surface — an MCP server that hands coding agents the framework's machine-readable truth: exact API signatures, live routes and schema, version-matched docs, and `zt doctor`.",
45
45
  "keywords": [