@zerotal/core 1.14.2 → 1.15.0

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/CHANGELOG.md CHANGED
@@ -8,6 +8,85 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.15.0] — 2026-09-04
12
+
13
+ ### Changed — **BREAKING**
14
+
15
+ - **BREAKING: a `_middleware.ts` that cannot apply now stops the boot.** Two ways one could
16
+ fail were silent, and both left the routes it was written to guard live and unguarded.
17
+ An app where either was happening will now refuse to boot until the file is fixed —
18
+ which is the point: it was not guarding anything before, and nothing said so. A directory
19
+ with no `_middleware` file is unchanged and stays silent. See the
20
+ [Upgrade Guide](/docs/upgrade#114-to-115).
21
+
22
+ ### Fixed
23
+
24
+ - **A `_middleware.ts` that default-exported its list left the subtree unguarded.** The
25
+ loader read only `export const middleware`, so `export default [EmployerMiddleware]` was
26
+ ignored — no warning, no boot error, nothing in `route:list`. The app started, the routes
27
+ answered, and the directory the file was written to protect served to anyone. `export
28
+ default` is the natural guess: every route file in the same directory default-exports its
29
+ handler.
30
+
31
+ The file now has to export a `middleware` array, and boot fails naming the file and the
32
+ fix when it does not. Nothing rejected here has ever applied, so no working app changes.
33
+
34
+ - **An import error in a `_middleware.ts` was swallowed with the missing-file case.** Both
35
+ shared one `catch`, so a typo, a bad import path, a circular import or a `SyntaxError` all
36
+ read as "this directory has no middleware" and the routes beneath went on serving — on a
37
+ hot-reload, a guard that had been there a second earlier.
38
+
39
+ Absence and failure are now separate: no file is silent, as the convention intends, and a
40
+ file that threw stops the boot with the original error and its path. Under `zt dev` the
41
+ reload aborts and the previously compiled routes keep serving, so the guard never drops.
42
+
43
+ - **`zt doctor`'s secure-headers check failed on any app behind a reverse proxy.** It read
44
+ `app.secureHeaders.secure` and inferred the response from it, so a deployment where Caddy
45
+ or nginx terminates TLS and sets HSTS — the most common production topology for a Bun app
46
+ — was reported as downgradeable while `curl -I` showed the header present. A check that is
47
+ wrong on the ordinary case is one people learn to skip.
48
+
49
+ It now makes one request to `app.url` on a deployment and reports what came back. When it
50
+ cannot reach the site, it warns with what it actually knows — the app does not send HSTS,
51
+ confirm the proxy does — instead of asserting a header is missing. Unreachable is no
52
+ longer reported as absent.
53
+
54
+ ### Added
55
+
56
+ - **`zt route:types` regenerates every type file the app generates from its file tree**, not
57
+ only `types/routes.generated.ts`. `@zerotal/inertia` registers its page registry through
58
+ the new `registerTypeGenerator`, so adding a page and running the command whose name says
59
+ it generates types now does. Previously it regenerated the routes half, `Inertia.render`
60
+ still failed with `TS2345: … not assignable to 'PageName'`, and the error reads as a typo
61
+ in the page name rather than a registry that has not been rebuilt. `--check` gates on all
62
+ of them.
63
+
64
+ - **`zt test` no longer inherits `.env`'s mail, queue, session and cache drivers.** Bun loads
65
+ `.env` into every process, so a developer with `MAIL_DRIVER=smtp` pointed at a local
66
+ Postfix got a suite where every path that sends mail opened a real SMTP connection — one
67
+ test going from milliseconds to a five-second timeout, failing only when run with its
68
+ siblings, and invisible to anyone without a mail server configured.
69
+
70
+ These four now get their in-process defaults (`log`, `sync`, `cookie`, `memory`), which is
71
+ what the app's own config already defaults to. A value set in the shell still wins, so
72
+ `MAIL_DRIVER=smtp bun zt test` works; `.env.test` is read and merged last; `--keep-env`
73
+ turns the whole thing off. The command prints what it changed.
74
+
75
+ ## [1.14.3] — 2026-09-01
76
+
77
+ ### Fixed
78
+
79
+ - **`zt upgrade` never offered the 1.13.0 migrations.** `deprecated-aliases` bundled two
80
+ retirements that shipped in 1.13.0 — `serve --dev` and `routes:types` — with the
81
+ `BaseModel` rename that has not shipped and is scheduled for 2.0. A codemod carries one
82
+ version, so it took the later one, and `zt upgrade --to 1.13.0` selected nothing at all.
83
+ The migration for the release that broke `serve --dev` was unreachable by every app
84
+ crossing it.
85
+
86
+ Split into `deprecated-aliases` at 1.13.0 and `base-model-rename` at 2.0.0. An app moving
87
+ to any 1.13+ release now gets the command rewrites, and one crossing to 2.0 no longer has
88
+ `BaseModel` renamed as a side effect of a command alias.
89
+
11
90
  ## [1.14.2] — 2026-09-01
12
91
 
13
92
  ### Fixed
package/api-surface.md CHANGED
@@ -3,6 +3,9 @@
3
3
  <!-- AUTO-GENERATED by scripts/api-surface.ts. Do not edit by hand.
4
4
  Run `bun run api:surface` to regenerate after an intentional API change. -->
5
5
 
6
+ > Not a guide — this is a flat, alphabetical snapshot used to detect unintended
7
+ > API changes. For how to use these, read the [documentation](https://zerotal.dev/docs).
8
+
6
9
  ## . `(./src/index.ts)`
7
10
 
8
11
  class AppBooted = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/core",
3
- "version": "1.14.2",
3
+ "version": "1.15.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The registry of generated-type files that `bun zt route:types` refreshes.
3
+ *
4
+ * Route names are not the only thing a Zerotal app generates from its own
5
+ * filesystem — `@zerotal/inertia` emits a page registry that gives
6
+ * `Inertia.render(name, props)` its typed page names. Both answer the same
7
+ * question ("what does the tree contain, as types?"), both go stale the moment
8
+ * a file is added, and `route:types` is the command whose name says it fixes
9
+ * that. It used to fix only half of it: adding a page and running `route:types`
10
+ * left the page registry stale, and the resulting error —
11
+ *
12
+ * TS2345: Argument of type '"ops/orders"' is not assignable to 'PageName'
13
+ *
14
+ * — reads as "you typed the name wrong" rather than "nothing has regenerated
15
+ * the registry since you added the file", which sends you looking at the name.
16
+ *
17
+ * A view package registers its routine here from provider boot, so
18
+ * `@zerotal/core` never imports `@zerotal/inertia` (which would be circular).
19
+ * This mirrors `registerDevBuildHook` in `../dev/DevBuildHook.ts`, for the same
20
+ * reason and with the same shape.
21
+ */
22
+
23
+ /**
24
+ * What one generator wrote, for the command's summary output.
25
+ *
26
+ * @internal Part of the {@link registerTypeGenerator} contract, like
27
+ * {@link TypeGeneratorFn} — framework-package plumbing, not app-facing API.
28
+ */
29
+ export interface TypeGeneratorResult {
30
+ /** The file written, relative to the project root. */
31
+ file: string;
32
+ /** What it contains, phrased for a summary line (e.g. `12 pages`). */
33
+ summary: string;
34
+ /**
35
+ * Whether the file on disk differed from what the routine produced. Drives
36
+ * `route:types --check`, and lets the command say "Unchanged" rather than
37
+ * claiming a write it did not need to make.
38
+ */
39
+ changed: boolean;
40
+ }
41
+
42
+ /**
43
+ * A generated-types routine. `check` asks it to compare against disk and report,
44
+ * without writing — the CI gate that keeps a committed generated file honest.
45
+ *
46
+ * @internal
47
+ */
48
+ export type TypeGeneratorFn = (options: { check: boolean }) => Promise<TypeGeneratorResult>;
49
+
50
+ const _generators = new Map<string, TypeGeneratorFn>();
51
+
52
+ /**
53
+ * @internal Exported for view packages to wire their codegen into `route:types`.
54
+ *
55
+ * Register a generated-types routine under a package name. Registering the same
56
+ * name twice replaces the earlier routine, so a provider may safely register on
57
+ * every boot.
58
+ */
59
+ export function registerTypeGenerator(name: string, fn: TypeGeneratorFn): void {
60
+ _generators.set(name, fn);
61
+ }
62
+
63
+ /** @internal Forget every registered routine (tests only). */
64
+ export function _resetTypeGenerators(): void {
65
+ _generators.clear();
66
+ }
67
+
68
+ /**
69
+ * @internal Run every registered routine, in registration order.
70
+ *
71
+ * One routine throwing does not stop the others — a broken page registry should
72
+ * not cost you your route types — but the error is returned rather than
73
+ * swallowed, so the command can report it and exit non-zero.
74
+ */
75
+ export async function _runTypeGenerators(options: {
76
+ check: boolean;
77
+ }): Promise<{ name: string; result?: TypeGeneratorResult; error?: unknown }[]> {
78
+ const outcomes: { name: string; result?: TypeGeneratorResult; error?: unknown }[] = [];
79
+ for (const [name, fn] of _generators) {
80
+ try {
81
+ outcomes.push({ name, result: await fn(options) });
82
+ } catch (error) {
83
+ outcomes.push({ name, error });
84
+ }
85
+ }
86
+ return outcomes;
87
+ }
@@ -2,25 +2,34 @@ import { Command } from "../Command.ts";
2
2
  import type { FlagDef } from "../Command.ts";
3
3
  import { Router } from "../../router/Router.ts";
4
4
  import { writeRouteTypes, ROUTE_TYPES_FILE } from "../../router/routeTypes.ts";
5
+ import { _runTypeGenerators } from "../TypeGenerators.ts";
5
6
 
6
7
  /**
7
- * `bun zt route:types` — write `types/routes.generated.ts`, the name → pattern
8
- * map that makes `route()` type-checked.
8
+ * `bun zt route:types` — refresh every type file the app generates from its own
9
+ * filesystem: `types/routes.generated.ts`, the name → pattern map that makes
10
+ * `route()` type-checked, plus whatever the installed view packages register
11
+ * (`@zerotal/inertia` adds its page registry, which types `Inertia.render`).
12
+ *
13
+ * It generates more than routes because a developer who adds a page and gets a
14
+ * type error about its name reaches for the command whose name says it
15
+ * generates types — and used to get the same error back, having regenerated the
16
+ * wrong half. Both files answer "what does the tree contain, as types?", and
17
+ * both go stale on the same edits.
9
18
  *
10
19
  * Boots the application (`needsApp`) and reads `Router.namedRoutes`, so routes
11
20
  * a provider registers programmatically and names set via a route file's
12
21
  * `export const meta` are included — not just what the file-router's naming
13
22
  * convention would produce.
14
23
  *
15
- * Commit the generated file: editors and CI need it without booting the app.
16
- * `zt dev` refreshes it on every restart, and `--check` fails when the file on
17
- * disk no longer matches the routes, which is the CI gate that keeps it honest.
24
+ * Commit the generated files: editors and CI need them without booting the app.
25
+ * `zt dev` refreshes them on every restart, and `--check` fails when a file on
26
+ * disk no longer matches, which is the CI gate that keeps them honest.
18
27
  *
19
28
  * @category Diagnostics
20
29
  */
21
30
  export class RouteTypesCommand extends Command {
22
31
  static commandName = "route:types";
23
- static description = "Generate types/routes.generated.ts for typed route() names";
32
+ static description = "Generate the app's type files (routes, and any view package's registry)";
24
33
  static needsApp = true;
25
34
  static args = [];
26
35
  static flags: FlagDef[] = [
@@ -35,23 +44,51 @@ export class RouteTypesCommand extends Command {
35
44
  async run(): Promise<void> {
36
45
  const check = this.flags["check"] as boolean;
37
46
 
38
- const result = await writeRouteTypes(Router.namedRoutes, { check });
47
+ const routes = await writeRouteTypes(Router.namedRoutes, { check });
48
+
49
+ // The routes file, then whatever the installed view packages register — the
50
+ // Inertia page registry, today. Collected into one list so the command reports
51
+ // every generated file the same way, and so a failure in one does not cost
52
+ // you the other: half-regenerated types are how this got confusing to begin with.
53
+ const files: { file: string; summary: string; changed: boolean }[] = [
54
+ {
55
+ file: ROUTE_TYPES_FILE,
56
+ summary: `${routes.count} named routes`,
57
+ changed: routes.changed,
58
+ },
59
+ ];
60
+ let failed = false;
61
+
62
+ for (const { name, result, error } of await _runTypeGenerators({ check })) {
63
+ if (error) {
64
+ failed = true;
65
+ this.error(`${name}: ${error instanceof Error ? error.message : String(error)}`);
66
+ continue;
67
+ }
68
+ if (result) files.push(result);
69
+ }
39
70
 
40
71
  if (check) {
41
- if (result.changed) {
42
- this.error(`${result.path} is out of date. Run: bun zt route:types`);
43
- throw new Error("Route types are out of date.");
72
+ const stale = files.filter((f) => f.changed);
73
+ for (const f of files) {
74
+ if (f.changed) this.error(`${f.file} is out of date. Run: bun zt route:types`);
75
+ else this.info(`${f.file} is up to date (${f.summary}).`);
76
+ }
77
+ if (stale.length > 0) {
78
+ throw new Error(`Generated types are out of date: ${stale.map((f) => f.file).join(", ")}`);
44
79
  }
45
- this.info(`${result.path} is up to date (${result.count} named routes).`);
80
+ if (failed) throw new Error("A type generator failed.");
46
81
  return;
47
82
  }
48
83
 
49
- if (result.count === 0) {
84
+ if (routes.count === 0) {
50
85
  this.warn("No named routes found — the generated map is empty.");
51
86
  }
52
87
 
53
- this.info(
54
- `${result.changed ? "Wrote" : "Unchanged"}: ${ROUTE_TYPES_FILE} (${result.count} named routes)`,
55
- );
88
+ for (const f of files) {
89
+ this.info(`${f.changed ? "Wrote" : "Unchanged"}: ${f.file} (${f.summary})`);
90
+ }
91
+
92
+ if (failed) throw new Error("A type generator failed.");
56
93
  }
57
94
  }
@@ -1,5 +1,6 @@
1
1
  import { Command } from "../Command.ts";
2
2
  import { bunBinary } from "../../support/runtime.ts";
3
+ import { testEnvOverrides } from "../testEnv.ts";
3
4
 
4
5
  /**
5
6
  * `bun zt test [pattern] [flags]` — runs the test suite in the test environment.
@@ -11,6 +12,9 @@ import { bunBinary } from "../../support/runtime.ts";
11
12
  * withDatabase() and DB.table() work without manual beforeAll boilerplate.
12
13
  * - If @zerotal/testing is not installed the preload is silently skipped —
13
14
  * tests that use createTestApp() still work without it.
15
+ * - Resets the driver keys `.env` would otherwise lend the suite (mail, queue,
16
+ * session, cache) to their in-process defaults, and loads `.env.test` when
17
+ * the app has one. See `../testEnv.ts` for why, and `--keep-env` to opt out.
14
18
  *
15
19
  * All positional args and recognised flags are forwarded to bun test.
16
20
  *
@@ -72,6 +76,12 @@ export class TestCommand extends Command {
72
76
  description: "Run database/migrations against the test database before the suite",
73
77
  default: false,
74
78
  },
79
+ {
80
+ name: "keep-env",
81
+ type: "boolean" as const,
82
+ description: "Inherit .env's mail/queue/session/cache drivers instead of resetting them",
83
+ default: false,
84
+ },
75
85
  ];
76
86
 
77
87
  async run(): Promise<void> {
@@ -122,7 +132,18 @@ export class TestCommand extends Command {
122
132
  const timeout = (this.flags["timeout"] as number | undefined) || TestCommand.DEFAULT_TIMEOUT_MS;
123
133
  bunArguments.push(`--timeout=${timeout}`);
124
134
 
135
+ // `.env` is written for `zt dev`; a test run should decide for itself which
136
+ // outbound services it reaches. Reported rather than applied silently — a
137
+ // suite that behaves differently from the same code under `zt dev` has to
138
+ // say so, or the next person debugs the difference instead of reading it.
139
+ const { overrides, neutralised } = this.flags["keep-env"]
140
+ ? { overrides: {}, neutralised: [] as string[] }
141
+ : await testEnvOverrides(process.cwd(), Bun.env as Record<string, string | undefined>);
142
+
125
143
  this.dim(`APP_ENV=test ZT_DB_URL=${dbUrl} bun ${Bun.version}`);
144
+ if (neutralised.length > 0) {
145
+ this.dim(`Reset for tests: ${neutralised.join(" ")} (keep .env's with --keep-env)`);
146
+ }
126
147
  this.dim(`bun ${bunArguments.join(" ")}\n`);
127
148
 
128
149
  // `bunBinary()`, not `"bun"`. The child of a command whose entire job is to run
@@ -136,6 +157,7 @@ export class TestCommand extends Command {
136
157
  stderr: "inherit",
137
158
  env: {
138
159
  ...Bun.env,
160
+ ...overrides,
139
161
  APP_ENV: "test",
140
162
  ZT_DB_URL: dbUrl,
141
163
  } as Record<string, string | undefined>,
@@ -60,3 +60,10 @@ export { AssetsBuildCommand } from "./AssetsBuildCommand.ts";
60
60
  export { AssetsPruneCommand } from "./AssetsPruneCommand.ts";
61
61
  export { LintPackagesCommand } from "./LintPackagesCommand.ts";
62
62
  export { MakePackageCommand } from "./MakePackageCommand.ts";
63
+
64
+ // Extension point rather than a command: lets a view package add its own
65
+ // generated-types file to `zt route:types`, so one command refreshes every type
66
+ // file an app generates from its file tree. `@zerotal/inertia` registers its page
67
+ // registry here. See ../TypeGenerators.ts.
68
+ export { registerTypeGenerator } from "../TypeGenerators.ts";
69
+ export type { TypeGeneratorFn, TypeGeneratorResult } from "../TypeGenerators.ts";
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The environment `bun zt test` hands its child, and the one thing it takes away.
3
+ *
4
+ * Bun loads `.env` into every process it starts, so a test run inherits the
5
+ * developer's whole local setup — including the driver keys that decide whether
6
+ * a code path talks to something real. A developer with `MAIL_DRIVER=smtp`
7
+ * pointed at a local Postfix gets a suite where every path that sends mail opens
8
+ * an SMTP connection: one confirmed payment that issues an invitation goes from
9
+ * milliseconds to a five-second timeout, and fails only when run with its
10
+ * siblings. A developer with no mail server configured never sees it, which
11
+ * makes it precisely the kind of failure that lands on one person and looks
12
+ * like flakiness to everyone else.
13
+ *
14
+ * So the drivers below are reset for the test child. Not because `.env` is
15
+ * wrong — it is right for `zt dev`, which is what it was written for — but
16
+ * because "which outbound service does this reach" is a question a test run
17
+ * should answer for itself. The app's own config already defaults to the same
18
+ * safe values; it is only `.env` that overrides them.
19
+ *
20
+ * Two things still win over this:
21
+ * - a value set in the shell (`MAIL_DRIVER=smtp bun zt test`), which is
22
+ * someone deliberately testing that path;
23
+ * - a value in `.env.test`, which is the same intent written down. Bun only
24
+ * loads that file when `NODE_ENV=test`, so it is read here explicitly rather
25
+ * than left as a file the docs mention and nothing loads.
26
+ */
27
+
28
+ /**
29
+ * Driver keys reset for a test run, and what they are reset to. Each default is
30
+ * the in-process option: no socket, no external service, no shared state between
31
+ * runs — and each is already the app config's own default.
32
+ */
33
+ export const TEST_SAFE_DRIVERS: Readonly<Record<string, string>> = {
34
+ /** Writes the message to the log instead of opening an SMTP connection. */
35
+ MAIL_DRIVER: "log",
36
+ /** Runs a dispatched job inline, so a test sees its effects without a worker. */
37
+ QUEUE_DRIVER: "sync",
38
+ /** Keeps the session in the cookie rather than reaching for Redis. */
39
+ SESSION_DRIVER: "cookie",
40
+ /** In-process cache, so one test file cannot see another's entries. */
41
+ CACHE_DRIVER: "memory",
42
+ };
43
+
44
+ /**
45
+ * Parse a dotenv file into a map. Deliberately small: this is used to find out
46
+ * *which keys a file mentions*, not to load anything — Bun has already loaded
47
+ * `.env` by the time this runs, and re-implementing its full semantics here
48
+ * would only produce a second, disagreeing answer.
49
+ *
50
+ * @param text - Raw file contents.
51
+ * @returns Key → value, with surrounding quotes stripped.
52
+ */
53
+ export function parseDotenv(text: string): Map<string, string> {
54
+ const entries = new Map<string, string>();
55
+ for (const raw of text.split(/\r?\n/)) {
56
+ const line = raw.trim();
57
+ if (line === "" || line.startsWith("#")) continue;
58
+
59
+ const eq = line.indexOf("=");
60
+ if (eq <= 0) continue;
61
+
62
+ const key = line
63
+ .slice(0, eq)
64
+ .trim()
65
+ .replace(/^export\s+/, "");
66
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
67
+
68
+ let value = line.slice(eq + 1).trim();
69
+ const quote = value[0];
70
+ if ((quote === '"' || quote === "'") && value.endsWith(quote) && value.length > 1) {
71
+ value = value.slice(1, -1);
72
+ } else {
73
+ // Unquoted values run to a trailing comment, matching dotenv.
74
+ value = value.replace(/\s+#.*$/, "");
75
+ }
76
+ entries.set(key, value);
77
+ }
78
+ return entries;
79
+ }
80
+
81
+ /** Read and parse a dotenv file, or an empty map when it does not exist. */
82
+ async function _read(path: string): Promise<Map<string, string>> {
83
+ const text = await Bun.file(path)
84
+ .text()
85
+ .catch(() => null);
86
+ return text === null ? new Map() : parseDotenv(text);
87
+ }
88
+
89
+ /**
90
+ * Work out what to change about the child process's environment.
91
+ *
92
+ * @param cwd - Project root holding the dotenv files.
93
+ * @param env - The parent's environment, as the child would otherwise inherit it.
94
+ * @returns Keys to override, and the subset of those that were neutralised drivers (for reporting).
95
+ */
96
+ export async function testEnvOverrides(
97
+ cwd: string,
98
+ env: Record<string, string | undefined>,
99
+ ): Promise<{ overrides: Record<string, string>; neutralised: string[] }> {
100
+ const [dotenv, dotenvLocal, dotenvTest] = await Promise.all([
101
+ _read(`${cwd}/.env`),
102
+ _read(`${cwd}/.env.local`),
103
+ _read(`${cwd}/.env.test`),
104
+ ]);
105
+
106
+ const overrides: Record<string, string> = {};
107
+ const neutralised: string[] = [];
108
+
109
+ for (const [key, safe] of Object.entries(TEST_SAFE_DRIVERS)) {
110
+ const current = env[key];
111
+
112
+ // Unset means the app config's default applies, which is already the safe
113
+ // one — setting it anyway would only paper over a config the app changed
114
+ // on purpose.
115
+ if (current === undefined) continue;
116
+
117
+ // Already safe: nothing to say, and nothing to report.
118
+ if (current === safe) continue;
119
+
120
+ // Set in the shell rather than in a file — someone testing this path on
121
+ // purpose. Inferred by comparing against what the files declare, which is
122
+ // the only handle available: Bun merges dotenv into the environment before
123
+ // any of this runs, and does not record where a value came from.
124
+ const fromFile = dotenv.get(key) === current || dotenvLocal.get(key) === current;
125
+ if (!fromFile) continue;
126
+
127
+ overrides[key] = safe;
128
+ neutralised.push(`${key}=${safe}`);
129
+ }
130
+
131
+ // `.env.test` last: it is the app saying what a test run should use, which
132
+ // outranks both the inherited value and the default chosen here.
133
+ for (const [key, value] of dotenvTest) {
134
+ overrides[key] = value;
135
+ const index = neutralised.findIndex((entry) => entry.startsWith(`${key}=`));
136
+ if (index !== -1) neutralised.splice(index, 1);
137
+ }
138
+
139
+ return { overrides, neutralised };
140
+ }
@@ -351,22 +351,88 @@ const corsWildcardCheck: DoctorCheck = {
351
351
  * false and has never had production auto-detection. A deployment that never set it
352
352
  * serves no `Strict-Transport-Security` at all, so a visitor's first request each
353
353
  * time stays downgradeable to plain HTTP.
354
+ *
355
+ * The header is the only thing that settles this, and the header is not always the
356
+ * app's to send. Reading `app.secureHeaders.secure` and inferring the response from
357
+ * it failed on the most ordinary production topology there is: a reverse proxy
358
+ * terminating TLS and setting HSTS itself. The app's config is then correctly
359
+ * false, the header is correctly present, and the check said the site could be
360
+ * downgraded — a wrong answer on a check that is otherwise reliable enough to be
361
+ * read, which is how a check becomes the line people learn to skip.
362
+ *
363
+ * So it probes when it can (`--url`, or `app.url` on a deployment: the response is
364
+ * the only thing that answers the question) and, when it cannot, says what it
365
+ * actually knows — the app does not send HSTS — rather than what it was guessing.
354
366
  */
355
367
  const secureHeadersCheck: DoctorCheck = {
356
368
  id: "secure-headers",
357
369
  label: "Secure headers",
358
- run(app) {
370
+ async run(app) {
359
371
  const secure = _config(app, "app.secureHeaders.secure") === true;
360
372
  if (secure) return ok("HSTS enabled.");
361
373
  if (!_isProductionEnv(app)) return ok("HSTS off — expected outside a deployment.");
362
- return fail(
363
- "app.secureHeaders.secure is not true, so no Strict-Transport-Security header is " +
364
- "sent and the first request of each visit can be downgraded to HTTP.",
365
- "Set app.secureHeaders.secure: true in config/app.ts once the site is served over HTTPS.",
374
+
375
+ // One request to the public URL, which is the only view that includes the
376
+ // proxy. A loopback `app.url` on a deployment is its own finding, already
377
+ // reported by the transport-origins check.
378
+ const url = _config(app, "app.url");
379
+ if (typeof url === "string" && url !== "" && !_isLoopback(url)) {
380
+ const observed = await _observedHsts(url);
381
+ if (observed === true) {
382
+ return ok(`HSTS served by the proxy (${url}) — the app does not set it.`);
383
+ }
384
+ if (observed === false) {
385
+ return fail(
386
+ `No Strict-Transport-Security on ${url}: neither the app nor anything in ` +
387
+ `front of it sends it, so the first request of each visit can be downgraded ` +
388
+ `to HTTP.`,
389
+ "Set app.secureHeaders.secure: true in config/app.ts, or add the header at the proxy.",
390
+ );
391
+ }
392
+ // Unreachable: the deployment may not be up yet, or `doctor` may be running
393
+ // somewhere that cannot see the public URL. That is not evidence either way.
394
+ }
395
+
396
+ return warn(
397
+ "app.secureHeaders.secure is not true, so the app does not send " +
398
+ "Strict-Transport-Security. If a reverse proxy terminates TLS it may be sending " +
399
+ "it — confirm there; if nothing does, the first request of each visit can be " +
400
+ "downgraded to HTTP.",
401
+ `Check with: curl -sI ${typeof url === "string" && url !== "" ? url : "https://your-site"} | grep -i strict-transport-security\n` +
402
+ " Then either set app.secureHeaders.secure: true in config/app.ts, or leave it " +
403
+ "to the proxy — but not both, which sends the header twice.",
366
404
  );
367
405
  },
368
406
  };
369
407
 
408
+ /**
409
+ * Whether the deployed site answers with `Strict-Transport-Security`.
410
+ *
411
+ * `null` when the request could not be made at all, which is different from "no
412
+ * header" and must not be reported as one.
413
+ */
414
+ async function _observedHsts(url: string): Promise<boolean | null> {
415
+ try {
416
+ const response = await fetch(url, {
417
+ method: "HEAD",
418
+ // `redirect: "manual"`: the redirect's own response is the one the proxy wrote,
419
+ // and following it would report the destination's headers instead.
420
+ redirect: "manual",
421
+ // `doctor` is what people run immediately after a deploy, so it may not hang on
422
+ // an unreachable host — a diagnostic that stops responding is worse than one
423
+ // that says it could not tell. A timeout lands in the `catch` as `null`, which
424
+ // is already "no evidence either way" rather than "no header".
425
+ signal: AbortSignal.timeout(_PROBE_TIMEOUT_MS),
426
+ });
427
+ return response.headers.get("strict-transport-security") !== null;
428
+ } catch {
429
+ return null;
430
+ }
431
+ }
432
+
433
+ /** How long the HSTS probe waits before reporting that it could not tell. */
434
+ const _PROBE_TIMEOUT_MS = 5_000;
435
+
370
436
  /**
371
437
  * A rate limiter behind a proxy that was never told about the proxy keys every
372
438
  * visitor to the same bucket, because the socket address is the proxy's. It then
package/src/index.ts CHANGED
@@ -245,6 +245,9 @@ export type {
245
245
  } from "./router/Route.ts";
246
246
  export { scanFileRoutes, registerFileRouteResolver } from "./router/FileRouter.ts";
247
247
  export type { FileRouteResolver, FileRouteContext } from "./router/FileRouter.ts";
248
+ // `registerTypeGenerator` — the hook that lets a view package add its own
249
+ // generated-types file to `zt route:types` — lives on `@zerotal/core/commands`,
250
+ // not here: it is command plumbing, and this barrel is the frozen lean kernel.
248
251
  export type {
249
252
  FileHandler,
250
253
  RouteModule,
@@ -312,25 +312,73 @@ async function _collectMiddleware(
312
312
  // sibling routes stayed live and unguarded, with no error and no log. A convention
313
313
  // that fails open is worse than one that does not exist.
314
314
  const middlewarePath = await _findMiddlewareFile(baseDir, dir);
315
+ // No file is the convention working — most directories have none. Everything past
316
+ // this point is a file somebody wrote in order to guard a subtree, so every way it
317
+ // can fail to apply is loud. These two cases used to share one `catch`, which made
318
+ // "there is no middleware here" and "the middleware is broken" indistinguishable.
319
+ if (!middlewarePath) continue;
320
+
321
+ // Convert to a file:// URL so dynamic import works on Windows (raw
322
+ // absolute paths with backslashes are not valid import specifiers).
323
+ // Append ?t=<reloadId> to bust the module cache on hot-reload.
324
+ const href = pathToFileURL(middlewarePath).href;
325
+ const importUrl = reloadId ? `${href}?t=${reloadId}` : href;
326
+
327
+ let loadedModule: MiddlewareModule & { default?: unknown };
315
328
  try {
316
- if (middlewarePath) {
317
- // Convert to a file:// URL so dynamic import works on Windows (raw
318
- // absolute paths with backslashes are not valid import specifiers).
319
- // Append ?t=<reloadId> to bust the module cache on hot-reload.
320
- const href = pathToFileURL(middlewarePath).href;
321
- const importUrl = reloadId ? `${href}?t=${reloadId}` : href;
322
- const loadedModule = (await import(importUrl)) as MiddlewareModule;
323
- if (Array.isArray(loadedModule.middleware)) {
324
- result.push(...loadedModule.middleware);
325
- }
326
- }
327
- } catch {
328
- // The file is missing or failed to import — skip it.
329
+ loadedModule = (await import(importUrl)) as MiddlewareModule & { default?: unknown };
330
+ } catch (error) {
331
+ // A typo, a bad import path, a circular import, a SyntaxError — swallowing any of
332
+ // these left the subtree beneath serving normally with its guard silently gone,
333
+ // and on a hot-reload, gone a second after it was there. Boot stops instead.
334
+ throw new Error(
335
+ `[Zerotal] Failed to import ${middlewarePath}: ` +
336
+ `${error instanceof Error ? error.message : String(error)}\n` +
337
+ `Routes under this directory would serve unguarded, so boot stops here. ` +
338
+ `Fix the file, or delete it if the subtree needs no middleware.`,
339
+ { cause: error },
340
+ );
341
+ }
342
+
343
+ if (Array.isArray(loadedModule.middleware)) {
344
+ result.push(...loadedModule.middleware);
345
+ continue;
329
346
  }
347
+
348
+ // The file loaded and exports nothing the loader can apply — the same fail-open in
349
+ // a different disguise. `export default` is the common one: every route file in the
350
+ // same directory default-exports its handler, so it is the natural guess, and it
351
+ // used to leave the subtree open while looking exactly like a guarded one. Throwing
352
+ // cannot break a working app, because no shape rejected here has ever applied.
353
+ throw new Error(_middlewareExportError(middlewarePath, loadedModule));
330
354
  }
331
355
  return result;
332
356
  }
333
357
 
358
+ /**
359
+ * The boot error for a `_middleware` file that exports nothing the loader can apply,
360
+ * naming the specific mistake so the message carries its own fix.
361
+ */
362
+ function _middlewareExportError(
363
+ path: string,
364
+ loadedModule: MiddlewareModule & { default?: unknown },
365
+ ): string {
366
+ const fix = " export const middleware = [YourMiddleware];";
367
+ const head = `[Zerotal] ${path} does not export a \`middleware\` array, so it guards nothing.`;
368
+
369
+ if (loadedModule.default !== undefined) {
370
+ return (
371
+ `${head}\n` +
372
+ `It has a \`default\` export, which this file does not use — route files ` +
373
+ `default-export their handler, but a _middleware file is read by name:\n${fix}`
374
+ );
375
+ }
376
+ if (loadedModule.middleware !== undefined) {
377
+ return `${head}\n\`middleware\` must be an array, even for a single entry:\n${fix}`;
378
+ }
379
+ return `${head}\nAdd the export, or delete the file if the subtree needs no middleware:\n${fix}`;
380
+ }
381
+
334
382
  /**
335
383
  * Combine the stacked `_middleware.ts` chain (`base`) with a route file's own
336
384
  * `export const middleware` for a specific verb. Array form applies to every method;
@@ -1,24 +1,19 @@
1
1
  /**
2
2
  * Ledger #4 — retire the deprecated aliases.
3
3
  *
4
- * Three pairs, each a live alias somebody depends on, which is exactly why they
5
- * could not be dropped in a minor:
4
+ * Two codemods, not one, because they retire at different times and a codemod
5
+ * carries a single version:
6
6
  *
7
- * - `BaseModel` → `Model`. The same class object under its original name.
8
- * - `routes:types` → `route:types`. Registered as an alias in `CommandRunner`.
9
- * - `serve --dev` → `dev`. The flag still works; `dev` is the command it became.
7
+ * - {@link deprecatedAliases} at **1.13.0** — `routes:types` → `route:types` and
8
+ * `serve --dev` → `dev`. Both shipped; both fail loudly now.
9
+ * - {@link baseModelRename} at **2.0.0** — `BaseModel` → `Model`. Not shipped;
10
+ * sized on 1 Sep 2026 at 617 api-surface references and moved to 2.0.
10
11
  *
11
- * ## What this deliberately does not do
12
- *
13
- * **It does not touch `BaseModel` in a type position.** `Model` and `BaseModel`
14
- * are the same runtime class, so a value-position rename is cosmetic and safe.
15
- * In a generic bound — `<T extends BaseModel>` — the name may be load-bearing
16
- * for a reader even though it resolves identically, and in framework source it
17
- * genuinely is. Those are reported for a person instead.
18
- *
19
- * That asymmetry is the whole reason this is a codemod rather than a
20
- * find-and-replace: the mixin-composition script learned it the hard way in
21
- * 1.3.0, where a blind rename broke import specifiers it had not considered.
12
+ * They were one codemod, which forced one version, which took the later of the
13
+ * two — so `zt upgrade --to 1.13.0` selected nothing and offered no help
14
+ * migrating the very release that retired `serve --dev`, while an app crossing to
15
+ * 2.0 would have had its `BaseModel` renamed as a side effect of a command alias.
16
+ * A codemod nobody's upgrade reaches is a codemod that does not exist.
22
17
  */
23
18
  import type { Change, Codemod, CodemodResult, Manual, SourceFile } from "../types.ts";
24
19
 
@@ -52,10 +47,53 @@ const COMMAND_ALIASES: { find: RegExp; replace: string; label: string }[] = [
52
47
  },
53
48
  ];
54
49
 
50
+ /** The command aliases retired in 1.13.0. Both fail loudly rather than drifting. */
55
51
  export const deprecatedAliases: Codemod = {
56
- version: "2.0.0",
52
+ version: "1.13.0",
57
53
  name: "deprecated-aliases",
58
- description: "Retire BaseModel, routes:types and serve --dev in favour of their real names",
54
+ description: "Retire routes:types and serve --dev in favour of their real names",
55
+ ledger: 4,
56
+
57
+ run(files: SourceFile[]): CodemodResult {
58
+ const changes: Change[] = [];
59
+
60
+ for (const { file, contents } of files) {
61
+ let next = contents;
62
+ const notes: string[] = [];
63
+
64
+ for (const { find, replace, label } of COMMAND_ALIASES) {
65
+ const hits = next.match(find)?.length ?? 0;
66
+ if (hits === 0) continue;
67
+ next = next.replace(find, replace);
68
+ notes.push(`${hits} × ${label}`);
69
+ }
70
+
71
+ if (next !== contents) changes.push({ file, summary: notes.join(", "), contents: next });
72
+ }
73
+
74
+ return { changes, manual: [] };
75
+ },
76
+ };
77
+
78
+ /**
79
+ * `BaseModel` → `Model`, which has **not shipped** and is scheduled for 2.0.
80
+ *
81
+ * ## What this deliberately does not do
82
+ *
83
+ * **It does not touch `BaseModel` in a type position.** `Model` and `BaseModel`
84
+ * are the same runtime class, so a value-position rename is cosmetic and safe.
85
+ * In a generic bound — `<T extends BaseModel>` — the name may be load-bearing
86
+ * for a reader even though it resolves identically, and in framework source it
87
+ * genuinely is. Those are reported for a person instead.
88
+ *
89
+ * That asymmetry is the whole reason this is a codemod rather than a
90
+ * find-and-replace: the mixin-composition script learned it the hard way in
91
+ * 1.3.0, where a blind rename broke import specifiers it had not considered.
92
+ */
93
+ export const baseModelRename: Codemod = {
94
+ version: "2.0.0",
95
+ name: "base-model-rename",
96
+ description: "Rename BaseModel to Model at every declaration site",
59
97
  ledger: 4,
60
98
 
61
99
  run(files: SourceFile[]): CodemodResult {
@@ -88,13 +126,6 @@ export const deprecatedAliases: Codemod = {
88
126
  notes.push("import specifier updated");
89
127
  }
90
128
 
91
- for (const { find, replace, label } of COMMAND_ALIASES) {
92
- const hits = next.match(find)?.length ?? 0;
93
- if (hits === 0) continue;
94
- next = next.replace(find, replace);
95
- notes.push(`${hits} × ${label}`);
96
- }
97
-
98
129
  // Type positions are a handover, not a rewrite.
99
130
  contents.split("\n").forEach((line, i) => {
100
131
  if (!BASE_MODEL_TYPE.test(line)) return;
@@ -8,9 +8,9 @@
8
8
  * that rule fails is by everyone assuming there is time.
9
9
  */
10
10
  import type { Codemod } from "../types.ts";
11
- import { deprecatedAliases } from "./deprecated-aliases.ts";
11
+ import { deprecatedAliases, baseModelRename } from "./deprecated-aliases.ts";
12
12
  import { clientTaggedTemplate } from "./client-tagged-template.ts";
13
13
 
14
- export const CODEMODS: Codemod[] = [clientTaggedTemplate, deprecatedAliases];
14
+ export const CODEMODS: Codemod[] = [clientTaggedTemplate, deprecatedAliases, baseModelRename];
15
15
 
16
- export { deprecatedAliases, clientTaggedTemplate };
16
+ export { deprecatedAliases, baseModelRename, clientTaggedTemplate };