@zerotal/arch 1.13.2 → 1.13.4

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,125 @@ 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.4 — 2026-09-01
31
+
32
+ **Take this one if you are on 1.13.3.** The site gate shipped a staff bypass that failed
33
+ open, and a type error that landed on apps not using it. Both found by a team upgrading,
34
+ within a day.
35
+
36
+ ### Fixed
37
+
38
+ - **The gate's staff bypass was a denylist, and let the public in.** 1.13.3 admitted any
39
+ authenticated user whose role was not literally `"customer"`. In an app whose roles are
40
+ `user` and `admin` — which is most of them — that is every signed-in visitor, so a
41
+ private preview showed the site to anyone with an account. A gate that fails open is
42
+ worse than no gate, because it reports success while doing nothing.
43
+
44
+ It is an allowlist now: `gate.staffRoles`, defaulting to `["admin"]`. An app whose staff
45
+ role is named something else gets no bypass and notices, which is the safe direction to
46
+ be wrong in. The token path is unchanged.
47
+
48
+ Worth naming the mistake, because this release cycle already contained its lesson: the
49
+ 1.11.0 notes describe an app that wrote its own "is this error permanent" check as a
50
+ denylist and found it was a latent outage, and the fix was an allowlist. The same shape
51
+ went into the gate three releases later.
52
+
53
+ - **The same line broke `tsc` for apps that do not use the gate.** `role !== "customer"`
54
+ is a type error when an app's role union has no such member (`TS2367`), and the
55
+ framework ships TypeScript source, so the error arrived on a feature the app never
56
+ touched — failing its build. The role is read as `string` now, because the framework
57
+ cannot know an app's role names and must not narrow to them.
58
+
59
+ ### Documented
60
+
61
+ - **A tilde still crosses a patch, and under this scheme a patch carries features.**
62
+ `~1.13.2` is `>=1.13.2 <1.14.0`, so it takes 1.13.3 without asking — which is exactly
63
+ how 1.13.3 reached the app that found the bugs above. That is the right default for most
64
+ apps, and it is weaker protection than the same range gives under strict semver, where a
65
+ patch is only ever a bug fix. [The upgrade guide](/docs/upgrade#versioning) now says so,
66
+ and says to pin the exact version when you need it to hold.
67
+
68
+ ## 1.13.3 — 2026-08-31
69
+
70
+ Two things an app cannot see about itself, from two field reports. Both are the same
71
+ shape as most of this month's work: state that is real, consequential, and invisible from
72
+ inside the process that would want to know it.
73
+
74
+ ### Added
75
+
76
+ - **A site gate — maintenance, and private preview.**
77
+ [Guide](/docs/site-gate) · `zt down` · `zt preview` · `zt up` · `zt gate:status`
78
+
79
+ Proposed by a team running a hand-edited `basic_auth` block in their reverse proxy,
80
+ deliberately kept out of version control so it could not be deployed and forgotten into
81
+ a live shop. That precaution is the feature request: the gate belongs where the app can
82
+ reason about it.
83
+
84
+ Two states that look alike and are not. **Maintenance** means the site is down —
85
+ everyone refused, staff included, because the usual reason a site is down is that its
86
+ database is being changed underneath it. **Private preview** means the site is up and
87
+ working, for the people invited to it, for weeks.
88
+
89
+ The details that make it framework work rather than app work:
90
+
91
+ - **Maintenance is always `503` with `Retry-After`, and is not configurable.** A
92
+ maintenance page served at `200` tells a search engine the apology is your homepage,
93
+ and it will index it as such.
94
+ - **A preview token is stripped from the URL on first use**, by redirecting to a
95
+ cookie. Left in the address bar it travels into `Referer` on every outbound link,
96
+ into analytics, and into screenshots.
97
+ - **Webhook paths must be declared** in `gate.allow`. A payment provider posting a
98
+ settlement into a maintenance window otherwise gets a 503 — a retry, a dropped
99
+ callback, or a payment your books never learn about.
100
+ - **The state is a file, and the token is stored hashed.** A flag in the database is
101
+ unreadable exactly when the database is what you are working on; a token in a file
102
+ is a credential in something every backup copies.
103
+ - **It covers `Router.raw()` routes.** Found by running it: this framework's own docs
104
+ site serves every `/docs/*` page from a raw route, so an early build gated the front
105
+ page — which is what a person checks — and left all the content public.
106
+ - **The state file is gitignored** by the scaffold, which is the entire point.
107
+
108
+ - **Worker liveness — `zt doctor` can tell whether anything is running your background
109
+ work.** [Schedules](/docs/scheduler#is-anything-actually-running-them) ·
110
+ [Queue](/docs/queue#is-a-worker-running)
111
+
112
+ An app could say what it _registered_ and nothing could say whether any of it ever
113
+ _ran_. The reported failure: a team shipped to production with no worker process, and
114
+ every scheduled task silently did not execute for weeks. No hold was released, no
115
+ reminder was sent, nothing logged — from the web process's point of view nothing was
116
+ wrong, and they found it by going looking.
117
+
118
+ ```
119
+ ✖ Scheduler — 3 schedule(s) registered, and no worker has ever checked in.
120
+ Nothing is running them.
121
+ fix: Start the worker process: `bun zt worker`.
122
+ ```
123
+
124
+ The beat lives in the **cache**, because the process reading `doctor` is not the process
125
+ running the work and often not the same machine — and your cache driver already decides
126
+ what shared state can see. On `memory`, which is private to each process, the check
127
+ **says it cannot tell** rather than reporting a missing worker: a check that cried wolf
128
+ on every app using that driver is one people would learn to skip, and then it would not
129
+ be there for the case it exists for.
130
+
131
+ `@zerotal/core/heartbeat` exposes the primitive if you want the same signal on an ops
132
+ page.
133
+
134
+ ### Fixed
135
+
136
+ - **`secureHeaders: false` no longer empties the kernel middleware.** It set the layer to
137
+ `[]`, which was the same thing as removing the headers right up until the site gate
138
+ joined it — at which point opting out of security headers would silently have taken the
139
+ gate with it. One feature's opt-out disabling another's is precisely what the gate is
140
+ otherwise about.
141
+
142
+ ### Documented
143
+
144
+ - **Minting `APP_KEY` without the code.** `key:generate` is part of the application, so it
145
+ exists only once a release is installed — awkward when preparing `.env` first, since
146
+ `migrate` wants the file and the file wants a key. `openssl rand -base64 32` produces
147
+ exactly what `key:generate` writes; the [deployment guide](/docs/deployment) now says so.
148
+
30
149
  ## 1.13.2 — 2026-08-31
31
150
 
32
151
  From a production field report at 1.12.0 — an Inertia + React app on SQLite, 117 routes,
@@ -213,6 +213,19 @@ DATABASE_URL=postgres://user:pass@db-host:5432/app
213
213
  and a `base64:`-prefixed one are accepted. Generate it **on the server** — a key carried
214
214
  from a laptop is a key that has been in a shell history and a scrollback buffer.
215
215
 
216
+ **On a first deploy you do not have to wait for the code.** `key:generate` is part of the
217
+ application, so it only exists once the release is installed — which is awkward if you are
218
+ preparing `.env` before that, since `migrate` wants the file and the file wants a key.
219
+ There is nothing framework-specific about the value:
220
+
221
+ ```bash fragment
222
+ openssl rand -base64 32
223
+ ```
224
+
225
+ That is exactly what `key:generate` produces — 32 random bytes, base64 — so a key minted
226
+ this way is indistinguishable from one it wrote. Use whichever fits the order you deploy
227
+ in.
228
+
216
229
  `APP_ENV` accepts deployment names like `production` and `staging`; they all normalize to
217
230
  the `web` runtime mode — they describe _where_ the app runs, not _how_. See
218
231
  [Configuration](/docs/config-system).
package/docs/index.md CHANGED
@@ -40,7 +40,7 @@ cd my-app && bun dev
40
40
  | See what's running in production | [Logger](/docs/logger) · [Monitor](/docs/monitor) · [Telemetry](/docs/telemetry) · [Health](/docs/health) |
41
41
  | Debug what it just did | [DevTools](/docs/devtools) · [Errors](/docs/errors) |
42
42
  | Work with a coding agent | [Agent Surface](/docs/arch) |
43
- | Ship it | [Deployment](/docs/deployment) · [Commands](/docs/commands) |
43
+ | Ship it | [Deployment](/docs/deployment) · [Site gate](/docs/site-gate) · [Commands](/docs/commands) |
44
44
 
45
45
  ## Choosing a frontend
46
46
 
@@ -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/queue.md CHANGED
@@ -353,6 +353,24 @@ needed, so chaining works with any driver.
353
353
 
354
354
  ## Processing jobs
355
355
 
356
+ ### Is a worker running?
357
+
358
+ Jobs waiting with nothing to run them is the same failure as an unrun schedule, one layer
359
+ over: the queue fills, nothing errors, and you find out when a customer asks where their
360
+ email went.
361
+
362
+ `zt doctor` reports it, keyed on the **pending depth** rather than on whether any job
363
+ class exists — an app with an empty queue and no worker may be perfectly fine:
364
+
365
+ ```
366
+ ✖ Queue worker — 4102 job(s) waiting, and no worker has ever checked in.
367
+ fix: Start the worker process: `bun zt worker`.
368
+ ```
369
+
370
+ The same shared-cache caveat applies as for [schedules](/docs/scheduler#is-anything-actually-running-them):
371
+ the beat lives in the cache, so a `memory` driver means the check stands aside rather
372
+ than guessing.
373
+
356
374
  ### Dedicated worker process
357
375
 
358
376
  The standard way to process jobs in production is a long-running worker process:
package/docs/scheduler.md CHANGED
@@ -301,6 +301,69 @@ Scheduled tasks (2)
301
301
  Next run 2026-06-22T06:00:00.000Z
302
302
  ```
303
303
 
304
+ ## Is anything actually running them?
305
+
306
+ Schedules register in the `worker` and `console` environments, not in `web`. That is the
307
+ right design — HTTP instances should not run cron — and it means **a second process is
308
+ required.** The framework starts happily without one.
309
+
310
+ An app shipped to production with no worker, and every scheduled task silently did not
311
+ execute for weeks. No hold was released, no reminder was sent, nothing logged, because
312
+ from the web process's point of view nothing was wrong. They found it by going looking.
313
+
314
+ `zt doctor` now looks for you:
315
+
316
+ ```
317
+ ✖ Scheduler — 3 schedule(s) registered, and no worker has ever checked in. Nothing is
318
+ running them. This is silent by nature — the web process has no way to notice, and
319
+ the work simply does not happen.
320
+ fix: Start the worker process: `bun zt worker`. It is a second process; the web
321
+ server does not run this.
322
+ ```
323
+
324
+ The worker records a check-in every minute while it runs. A check-in older than fifteen
325
+ minutes is a warning rather than a failure, because a worker mid-restart is not a
326
+ missing worker.
327
+
328
+ ### It needs a shared cache to mean anything
329
+
330
+ The beat is written to the cache, because the process reading `doctor` is not the
331
+ process running the schedules — and often not the same machine. Your cache driver
332
+ decides what that can see:
333
+
334
+ | Driver | Sees a worker on… |
335
+ | ------------------ | --------------------------------------- |
336
+ | `sqlite` (default) | another process on the same box |
337
+ | `redis` | another machine |
338
+ | `memory` | nothing — it is private to each process |
339
+
340
+ On `memory` the check **stands aside and says so** rather than reporting a missing
341
+ worker. A check that cried wolf on every app using the memory driver is one people would
342
+ learn to skip, and then it would not be there for the case it exists for.
343
+
344
+ ### Reading it yourself
345
+
346
+ The primitive is `@zerotal/core/heartbeat`, if you want the same signal on an ops page:
347
+
348
+ ```ts
349
+ import { Heartbeat } from "@zerotal/core/heartbeat";
350
+
351
+ const seen = await Heartbeat.lastSeen("scheduler");
352
+ // { status: "seen", ageSeconds, beat } | { status: "never" } | { status: "unknown", reason }
353
+ ```
354
+
355
+ | Name | Description |
356
+ | --------------------- | ---------------------------------------------------------------------------------------------------------- |
357
+ | `Heartbeat` | `beat()` records a check-in, `start()` beats on an interval and returns a stopper, `lastSeen()` reads one. |
358
+ | `Beat` | What a check-in records — `at`, `pid`, `detail`. |
359
+ | `BeatLookup` | The three answers: `seen`, `never`, and `unknown` with a reason. |
360
+ | `workerLivenessCheck` | Builds the doctor check above for a worker kind. |
361
+ | `describeBeat` | Renders a `BeatLookup` as the prose used in the report. |
362
+
363
+ `unknown` is a distinct answer from `never` on purpose, and every consumer has to keep
364
+ them apart: one means nothing is running, the other means this process cannot see whether
365
+ anything is.
366
+
304
367
  ## Run history
305
368
 
306
369
  Every completed execution — success or failure — is recorded to a capped JSONL
@@ -0,0 +1,190 @@
1
+ ---
2
+ title: Site gate
3
+ description: Maintenance mode and private preview — take a site down correctly, or open it only to the people you invite.
4
+ ---
5
+
6
+ # Site gate
7
+
8
+ Two states a site can be in that are not "serving the public", and they are not the
9
+ same thing.
10
+
11
+ **Maintenance** — the site is _down_. Nobody may use it, staff included, because the
12
+ usual reason a site is down is that its database is being changed underneath it, and
13
+ letting one person in is letting them into that. Minutes, not weeks. Every request is
14
+ answered `503` with `Retry-After`.
15
+
16
+ **Private preview** — the site is _up and working perfectly_, for the people invited to
17
+ it. Not down, not broken, not public yet. Weeks. An invited visitor gets the real site
18
+ at `200` and can transact on it; the public gets a holding page.
19
+
20
+ Building one and calling it both is the usual mistake, and it is why a pre-launch site
21
+ ends up behind a `503` that search engines take seriously.
22
+
23
+ ## Turning it on
24
+
25
+ ```bash
26
+ bun zt down --retry=120 # maintenance
27
+ bun zt preview # private preview; prints the link
28
+ bun zt preview --until=2026-09-30
29
+ bun zt up # open again
30
+ bun zt gate:status # what is it doing
31
+ ```
32
+
33
+ `zt preview` generates the token when you do not pass one, because a token typed by a
34
+ person is the only thing between the public and an unlaunched site and nothing
35
+ rate-limits guesses at it. **Keep the link it prints** — only a hash is stored, so the
36
+ token cannot be read back.
37
+
38
+ These four commands need no application and are registered in every environment. A
39
+ maintenance command that only works when the app boots is one you cannot reach at the
40
+ moment you need it.
41
+
42
+ ## From your own console
43
+
44
+ The framework's job is the primitive; the button is yours.
45
+
46
+ ```ts
47
+ import { Gate } from "@zerotal/core/gate";
48
+
49
+ await Gate.preview({ token, until: "2026-09-30", by: user.name });
50
+ await Gate.maintenance({ retryAfter: 120, by: user.name });
51
+ await Gate.open();
52
+
53
+ Gate.status(); // { mode, since, until, by, expired } — never the token
54
+ ```
55
+
56
+ ## Getting in
57
+
58
+ **A secret link.** `https://example.com/?preview=<token>`. The gate recognises the
59
+ token, sets a signed `HttpOnly` `SameSite=Lax` cookie, and **redirects to the same URL
60
+ with the parameter removed.**
61
+
62
+ That redirect is not tidiness. A token left in the address bar travels into `Referer`
63
+ on every outbound link, into analytics, into screenshots, and into the message where
64
+ somebody shares "the page I was looking at". Stripping it on first use leaves the
65
+ secret in a cookie and nowhere else.
66
+
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.
81
+
82
+ The cookie lasts seven days. A preview cookie with no lifetime means an ex-tester keeps
83
+ access to a site that has since gone live with real customer data.
84
+
85
+ **Rotating revokes.** `zt preview --token=<new>` invalidates every cookie issued under
86
+ the previous token, because the way a preview leaks is a tester forwarding the link to
87
+ somebody who has left.
88
+
89
+ ## What stays reachable
90
+
91
+ These are open whatever the gate is doing:
92
+
93
+ | Path | Why |
94
+ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
95
+ | `/__zerotal/*` | The health endpoint. Otherwise the uptime monitor pages the on-call about a planned window, and a deploy gate that polls health fails its own release. |
96
+ | `/css/*`, `/js/*`, `/assets/*`, `/favicon.ico` | A maintenance page that 503s its own stylesheet is an unstyled apology. |
97
+
98
+ **Your webhooks are not on that list, and this is the one that costs money.**
99
+
100
+ A payment provider posting a settlement into a maintenance window gets a `503`, and
101
+ depending on the provider that is a retry, a dropped callback, or a payment your books
102
+ never learn about. Nothing can infer which of your routes a third party calls, so it
103
+ has to be declared:
104
+
105
+ ```ts
106
+ // config/gate.ts
107
+ export default {
108
+ allow: ["/webhooks/", "/api/callbacks/"],
109
+ };
110
+ ```
111
+
112
+ ## What the public gets during a preview
113
+
114
+ `200` with a holding page by default, which is right for a pre-launch site collecting
115
+ an email address. Set `publicResponse: "notFound"` when the site's existence is itself
116
+ not public:
117
+
118
+ ```ts
119
+ // config/gate.ts
120
+ export default { publicResponse: "notFound" };
121
+ ```
122
+
123
+ Both are legitimate and the framework does not pick. **Maintenance is always `503`** and
124
+ is not configurable, because that one is not a preference: a maintenance page served at
125
+ `200` tells a search engine that "we will be back shortly" is the content of your
126
+ homepage, and it will index it as such. Sites have lost their rankings to a two-hour
127
+ window served at the wrong status code.
128
+
129
+ ## Where the state lives
130
+
131
+ `storage/framework/gate.json`, not the database.
132
+
133
+ A flag in the database is unreadable exactly when the database is the thing you are
134
+ working on, so a maintenance mode kept there works only on the days you did not need
135
+ it. The file is also why the gate survives a restart — an in-memory flag would be
136
+ lifted by the very deploy it was supposed to run behind.
137
+
138
+ The preview token is stored as a **hash**. The file sits on disk, readable by anything
139
+ on the box and copied by every backup, and putting a live credential in it would be the
140
+ same mistake as an `.env.example` carrying a working key.
141
+
142
+ A file the gate cannot parse reads as **open**, deliberately. Failing closed would take
143
+ a site down because a JSON file lost a brace, and "the site is up" is the safer error
144
+ for a mechanism whose whole purpose is to be turned off again.
145
+
146
+ ## It covers raw routes too
147
+
148
+ `Router.raw()` bypasses the middleware pipeline by design — that is what it is for. It
149
+ does **not** bypass the gate.
150
+
151
+ A gate that covered only the pipeline would be the worst kind, because it gates the
152
+ homepage and therefore looks like it works. This framework's own documentation site
153
+ serves every `/docs/*` page from a raw route: with an early build of the gate on, the
154
+ front page said "coming soon" and every page of content stayed public.
155
+
156
+ Raw routes get no staff bypass, since no session has been resolved by then — the token
157
+ cookie is the only way through.
158
+
159
+ ## `zt doctor` reports it
160
+
161
+ A gate is the most reversible thing in the framework and the easiest to forget, because
162
+ when it is working nothing complains: the people who would notice are the ones being
163
+ kept out, and they have no way to tell you.
164
+
165
+ - **Maintenance in production** is reported as a failure. It is an outage.
166
+ - **A preview in production** is a warning naming who set it and when, since a
167
+ pre-launch gate is usually deliberate.
168
+ - **A preview whose `until` has passed** is its own warning: the file says the site is
169
+ gated and it is not, so what a reader believes and what visitors get have come apart.
170
+
171
+ ## Reference
172
+
173
+ | Name | Description |
174
+ | -------------------- | ---------------------------------------------------------------------------- |
175
+ | `Gate` | Read and write the gate: `maintenance()`, `preview()`, `open()`, `status()`. |
176
+ | `GateStatus` | What `Gate.status()` returns. Never includes the token. |
177
+ | `MaintenanceOptions` | `retryAfter`, `by`. |
178
+ | `PreviewOptions` | `token`, `until`, `by`. |
179
+ | `GateMiddleware` | The kernel middleware that answers for a closed site. |
180
+ | `GateState` | The shape written to `storage/framework/gate.json`. |
181
+ | `GateMode` | `"maintenance" | "preview"`. |
182
+ | `readGate` | Read the state file, or `null` when the site is open. |
183
+ | `gateExpired` | Whether a preview's `until` has passed. |
184
+ | `GATE_FILE` | Path of the state file, relative to the project root. |
185
+ | `GATE_COOKIE` | Name of the cookie the gate issues. |
186
+ | `GATE_QUERY` | The query parameter that carries a token — `preview`. |
187
+ | `bun zt down` | `DownCommand` — maintenance on. |
188
+ | `bun zt preview` | `PreviewCommand` — private preview on, prints the link. |
189
+ | `bun zt up` | `UpCommand` — open the site. |
190
+ | `bun zt gate:status` | `GateStatusCommand` — what the gate is doing. |
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.2",
3
+ "version": "1.13.4",
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.2"
38
+ "@zerotal/core": "1.13.4"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0",
42
- "@zerotal/orm": "1.13.2"
42
+ "@zerotal/orm": "1.13.4"
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": [