@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 +119 -0
- package/docs/deployment.md +13 -0
- package/docs/index.md +1 -1
- package/docs/inertia/ssr.md +79 -40
- package/docs/queue.md +18 -0
- package/docs/scheduler.md +63 -0
- package/docs/site-gate.md +190 -0
- package/docs/upgrade.md +7 -0
- package/package.json +3 -3
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,
|
package/docs/deployment.md
CHANGED
|
@@ -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
|
|
package/docs/inertia/ssr.md
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
22
|
+
ssr: true,
|
|
38
23
|
});
|
|
39
24
|
```
|
|
40
25
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()`
|
|
86
|
-
| -------------- |
|
|
87
|
-
| Return type | `Promise<void>`
|
|
88
|
-
| Rendering |
|
|
89
|
-
| Response body | Fully buffered string
|
|
90
|
-
| TTFB |
|
|
91
|
-
| Page `<Head>` |
|
|
92
|
-
| XHR navigation | JSON (the normal path)
|
|
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
|
-
`
|
|
156
|
-
response body is the template with an empty root and the page object beside
|
|
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. **
|
|
183
|
-
collected, and the served `<head>` is the page's own. This is the
|
|
184
|
-
|
|
185
|
-
2. **
|
|
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.
|
|
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.
|
|
38
|
+
"@zerotal/core": "1.13.4"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^5.8.0",
|
|
42
|
-
"@zerotal/orm": "1.13.
|
|
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": [
|