@zerotal/core 1.6.3 → 1.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/core",
3
- "version": "1.6.3",
3
+ "version": "1.7.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -34,6 +34,7 @@
34
34
  },
35
35
  "files": [
36
36
  "CHANGELOG.md",
37
+ "api-surface.md",
37
38
  "src",
38
39
  "!src/**/*.test.ts",
39
40
  "!src/**/*.test.tsx",
@@ -27,7 +27,10 @@ import {
27
27
  import { builtinConcerns } from "../conventions/builtinConcerns.ts";
28
28
  import { ConfigManager } from "../config/ConfigManager.ts";
29
29
  import { DEFAULT_MAX_REQUEST_BODY_SIZE } from "../config/AppConfig.ts";
30
- import { SecureHeadersMiddleware } from "../middleware/SecureHeadersMiddleware.ts";
30
+ import {
31
+ SecureHeadersMiddleware,
32
+ staticSecurityHeaders,
33
+ } from "../middleware/SecureHeadersMiddleware.ts";
31
34
  import { isAllowedOrigin, allowedOriginsFrom } from "../http/originGuard.ts";
32
35
  import { rescueSync } from "../helpers/index.ts";
33
36
  import { configureAssets, setAssetVersion, assetVersion } from "../assets/assets.ts";
@@ -237,10 +240,13 @@ export async function _lazyStaticResponse(
237
240
  const relative = pathname.slice(trimmedPrefix.length).replace(/^\//, "");
238
241
  const file = Bun.file(`${rootDir}/${relative}`);
239
242
  if (await file.exists()) {
240
- return new Response(
241
- file as unknown as BodyInit,
242
- options?.headers ? { headers: options.headers } : undefined,
243
- );
243
+ // The same headers Bun's native static routes carry (see Router.compile).
244
+ // This path returns before the pipeline runs, so without it a file served
245
+ // lazily every file under the dev worker — would be the one response in
246
+ // the app with no security headers on it.
247
+ return new Response(file as unknown as BodyInit, {
248
+ headers: { ...staticSecurityHeaders(), ...options?.headers },
249
+ });
244
250
  }
245
251
  }
246
252
  return undefined;
@@ -260,6 +266,39 @@ export interface WebSocketHandlers {
260
266
  */
261
267
  export type AppScopeInstaller = () => () => void;
262
268
 
269
+ /**
270
+ * What one provider cost to boot, and what it contributed.
271
+ *
272
+ * @see Application.providerReport
273
+ */
274
+ export interface ProviderReport {
275
+ /** Provider class name. The array is in boot order. */
276
+ name: string;
277
+ /**
278
+ * Wall-clock milliseconds across all three lifecycle hooks.
279
+ *
280
+ * `onBooted` runs concurrently across providers, so these do not sum to the
281
+ * application's boot time — they overlap, and the report reports that rather
282
+ * than serialising the boot to produce a tidier number.
283
+ */
284
+ durationMs: number;
285
+ /** Container tokens this provider bound, as names. */
286
+ bindings: string[];
287
+ }
288
+
289
+ /**
290
+ * A container token as a readable name.
291
+ *
292
+ * Tokens are class constructors or plain strings; the map keys are neither
293
+ * sorted nor serialisable as they stand, and a reader wants `CacheManager`, not
294
+ * `[class CacheManager]`.
295
+ */
296
+ function _tokenName(token: unknown): string {
297
+ if (typeof token === "string") return token;
298
+ if (typeof token === "function") return token.name || "‹anonymous›";
299
+ return String(token);
300
+ }
301
+
263
302
  const _appScopeInstallers: AppScopeInstaller[] = [];
264
303
 
265
304
  /**
@@ -318,6 +357,8 @@ export class Application {
318
357
  _env: Environment = "web";
319
358
  private _booted = false;
320
359
  private _bootDurationMs: number | undefined = undefined;
360
+ /** Per-provider boot cost and container provenance; see {@link providerReport}. */
361
+ private _providerReport: ProviderReport[] = [];
321
362
  private _static?: ReturnType<typeof Bun.serve>;
322
363
  private _configMap: Record<string, Record<string, unknown>> | undefined = undefined;
323
364
  /** Tracks where config came from, to reject conflicting overrides via useConfig(). */
@@ -609,6 +650,60 @@ export class Application {
609
650
  return this._bootDurationMs;
610
651
  }
611
652
 
653
+ /**
654
+ * What each provider cost to boot, and what it put in the container.
655
+ *
656
+ * `bootDurationMs` says the app took 240ms and nothing said which provider
657
+ * spent it; the container lists a hundred bindings and nothing said who bound
658
+ * them. Both are answered here, in boot order — which is itself the answer to
659
+ * a third question, since provider order decides who wins a contested binding.
660
+ *
661
+ * Empty until `boot()` runs. Populated in every environment: it costs one
662
+ * `performance.now()` pair and one registry diff per provider at boot, and a
663
+ * report that only exists in development is one you cannot ask for when a
664
+ * staging boot is the slow one.
665
+ *
666
+ * @category Lifecycle
667
+ */
668
+ get providerReport(): readonly ProviderReport[] {
669
+ return this._providerReport;
670
+ }
671
+
672
+ /**
673
+ * Time one provider lifecycle hook and attribute anything it bound.
674
+ *
675
+ * Accumulates across the three phases, so a provider that binds in
676
+ * `onRegister` and spends its time in `onBooting` reads as one row.
677
+ */
678
+ private _recordProviderWork<T>(provider: ServiceProvider, work: () => T): T {
679
+ const name = provider.constructor.name;
680
+ let row = this._providerReport.find((entry) => entry.name === name);
681
+ if (!row) {
682
+ row = { name, durationMs: 0, bindings: [] };
683
+ this._providerReport.push(row);
684
+ }
685
+ const before = new Set(this.container.registry.keys());
686
+ const started = performance.now();
687
+ const finish = (): T => {
688
+ row!.durationMs = Math.round((row!.durationMs + performance.now() - started) * 100) / 100;
689
+ for (const token of this.container.registry.keys()) {
690
+ if (!before.has(token)) row!.bindings.push(_tokenName(token));
691
+ }
692
+ return undefined as T;
693
+ };
694
+ const result = work();
695
+ // An async hook is only finished when its promise is — timing it
696
+ // synchronously would report every `await` in it as free.
697
+ if (result instanceof Promise) {
698
+ return result.then((value: unknown) => {
699
+ finish();
700
+ return value;
701
+ }) as T;
702
+ }
703
+ finish();
704
+ return result;
705
+ }
706
+
612
707
  /**
613
708
  * The runtime environment this application is running in.
614
709
  *
@@ -1056,7 +1151,15 @@ export class Application {
1056
1151
  for (const callback of this._bindCallbacks) callback(this.container);
1057
1152
 
1058
1153
  // Phase 1 — synchronous, binds into container.
1059
- for (const provider of this._activeProviders) provider.onRegister();
1154
+ //
1155
+ // Timed, and the container is diffed around each provider, so the inspector
1156
+ // can answer "who bound `cache`, and what did booting it cost". Provenance
1157
+ // by diff rather than by having the container record a registrar: it keeps
1158
+ // the cost at boot instead of on every binding, and adds no mutable state to
1159
+ // the container for a question only a debugging tool asks.
1160
+ for (const provider of this._activeProviders) {
1161
+ this._recordProviderWork(provider, () => provider.onRegister());
1162
+ }
1060
1163
 
1061
1164
  // Config validation — providers have registered their namespace validators
1062
1165
  // in onRegister; run them before anything boots. In a production-like
@@ -1069,10 +1172,20 @@ export class Application {
1069
1172
  }
1070
1173
 
1071
1174
  // Phase 2 — sequential in registration order.
1072
- for (const provider of this._activeProviders) await provider.onBooting();
1175
+ for (const provider of this._activeProviders) {
1176
+ await this._recordProviderWork(provider, () => provider.onBooting());
1177
+ }
1073
1178
 
1074
1179
  // Phase 3 — async, all providers have finished booting.
1075
- await Promise.all(this._activeProviders.map((provider) => provider.onBooted()));
1180
+ //
1181
+ // These run concurrently, so the recorded durations overlap and do not sum
1182
+ // to the phase. That is the truth about this phase and the report says so
1183
+ // rather than serialising the boot to make a tidier number.
1184
+ await Promise.all(
1185
+ this._activeProviders.map((provider) =>
1186
+ this._recordProviderWork(provider, () => provider.onBooted()),
1187
+ ),
1188
+ );
1076
1189
 
1077
1190
  // Ensure config and events are resolved so makeSync() works below.
1078
1191
  await this.container.make("config");
@@ -2,15 +2,18 @@
2
2
  * `bun zt doctor` — run every static sanity check against this app and print
3
3
  * the findings with their fixes. Exits 1 when anything is broken outright.
4
4
  *
5
- * With `--url`, it also probes the deployed app's WebSocket transport from the outside,
6
- * through whatever proxy is in front of it. That is the only way to see the failures that
7
- * leave the app healthy from the inside and inert in the browser.
5
+ * With `--url`, it also reads the deployed app from the outside, through whatever proxy is
6
+ * in front of it the only way to see two classes of failure. The WebSocket transport,
7
+ * which a proxy can gate or drop while leaving the app healthy from the inside and inert in
8
+ * the browser. And security headers sent twice, because a header the app sets and the proxy
9
+ * also sets is invisible from in here: the app's own view is the value it wrote.
8
10
  */
9
11
  import { Command } from "../Command.ts";
10
12
  import type { Application } from "../../application/Application.ts";
11
13
  import { runDoctor } from "../../doctor/AppDoctor.ts";
12
14
  import type { DoctorReportEntry } from "../../doctor/AppDoctor.ts";
13
15
  import { probeTransport } from "../../doctor/TransportProbe.ts";
16
+ import { probeHeaders } from "../../doctor/HeaderProbe.ts";
14
17
 
15
18
  export class DoctorCommand extends Command {
16
19
  static override commandName = "doctor";
@@ -21,7 +24,8 @@ export class DoctorCommand extends Command {
21
24
  name: "url",
22
25
  type: "string" as const,
23
26
  description:
24
- "Also probe the deployed app's WebSocket transport at this public URL, as a browser would",
27
+ "Also read the deployed app at this public URL: handshake its WebSocket transport " +
28
+ "as a browser would, and report security headers the response carries twice",
25
29
  },
26
30
  ];
27
31
 
@@ -34,9 +38,13 @@ export class DoctorCommand extends Command {
34
38
  for (const entry of report) this._print(entry);
35
39
 
36
40
  const probeFailures = await this._probeTransport(app);
41
+ const headerFindings = await this._probeHeaders();
37
42
 
38
- const warns = report.filter((e) => e.result.status === "warn").length;
39
- const fails = report.filter((e) => e.result.status === "fail").length + probeFailures;
43
+ const warns = report.filter((e) => e.result.status === "warn").length + headerFindings.warnings;
44
+ const fails =
45
+ report.filter((e) => e.result.status === "fail").length +
46
+ probeFailures +
47
+ headerFindings.failures;
40
48
  this.newLine();
41
49
  // Throw rather than `process.exit(1)` — same exit code from the CLI (the runner
42
50
  // converts it), but composable. Exiting here killed any caller running the
@@ -86,6 +94,45 @@ export class DoctorCommand extends Command {
86
94
  return failures;
87
95
  }
88
96
 
97
+ /**
98
+ * Report security headers the deployed response carries more than once.
99
+ *
100
+ * Only reachable from outside: the app's own view of a header is the value it
101
+ * wrote, which is correct as far as it goes — the proxy's copy is invisible
102
+ * from in here. Conflicting values count as failures because a control that
103
+ * different browsers apply differently is not a control; identical duplicates
104
+ * are a warning, because they are a conflict waiting for someone to edit one
105
+ * side.
106
+ */
107
+ private async _probeHeaders(): Promise<{ failures: number; warnings: number }> {
108
+ const url = this.flags["url"] as string | undefined;
109
+ if (!url) return { failures: 0, warnings: 0 };
110
+
111
+ const findings = await probeHeaders(url);
112
+ this.newLine();
113
+ this.section("Response headers");
114
+
115
+ if (findings.length === 0) {
116
+ this.line("✓ No duplicated security headers.");
117
+ return { failures: 0, warnings: 0 };
118
+ }
119
+
120
+ let failures = 0;
121
+ let warnings = 0;
122
+ for (const finding of findings) {
123
+ const line = `${finding.conflicting ? "✗" : "!"} ${finding.header} — ${finding.message}`;
124
+ if (finding.conflicting) {
125
+ this.error(line);
126
+ failures++;
127
+ } else {
128
+ this.warn(line);
129
+ warnings++;
130
+ }
131
+ if (finding.fix) this.line(` fix: ${finding.fix}`);
132
+ }
133
+ return { failures, warnings };
134
+ }
135
+
89
136
  private _print({ check, result }: DoctorReportEntry): void {
90
137
  const mark = result.status === "ok" ? "✓" : result.status === "warn" ? "!" : "✗";
91
138
  const line = `${mark} ${check.label} — ${result.message}`;
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Read a deployed app's security headers from the outside, and report the ones
3
+ * that arrive twice.
4
+ *
5
+ * A header the app sets and the proxy also sets is invisible from inside the
6
+ * process: the app's own view is the value it wrote, and the app is right about
7
+ * that. Only a request that has been through the proxy sees both. Deploying
8
+ * zerotal.dev turned up exactly that — `X-Frame-Options: DENY` from the proxy
9
+ * and `SAMEORIGIN` from the app, on the same response — and browsers do not
10
+ * agree on which one wins. A security control that applies inconsistently is
11
+ * worse than one that is simply absent, because it looks configured.
12
+ *
13
+ * ## How a duplicate is visible at all
14
+ *
15
+ * `fetch` folds repeated headers into one comma-joined value, so
16
+ * `X-Frame-Options` sent twice reads back as `"DENY, SAMEORIGIN"`. For headers
17
+ * whose grammar has no comma in it that is unambiguous evidence of a duplicate.
18
+ * For `Permissions-Policy` and `Referrer-Policy` it is not — a comma is
19
+ * legitimate syntax there — so those are deliberately not checked. A probe that
20
+ * cried wolf on a correct `Permissions-Policy` would be switched off within a
21
+ * week, and then it would not catch the `X-Frame-Options` either.
22
+ */
23
+
24
+ /** One header's finding. */
25
+ export interface HeaderProbeResult {
26
+ /** The URL that was read. */
27
+ url: string;
28
+ /** The header, in the casing it is conventionally written. */
29
+ header: string;
30
+ /** The distinct values received, in the order sent. */
31
+ values: string[];
32
+ /** False when this needs attention. */
33
+ ok: boolean;
34
+ /** Whether the duplicated values disagree — the case browsers handle inconsistently. */
35
+ conflicting: boolean;
36
+ message: string;
37
+ fix?: string;
38
+ }
39
+
40
+ /**
41
+ * Headers that take exactly one value, so a comma in the received value means
42
+ * the header was sent more than once.
43
+ *
44
+ * `Permissions-Policy`, `Referrer-Policy` and `Accept-CH` are absent on purpose:
45
+ * each takes a comma-separated list, so duplication is undetectable this way.
46
+ */
47
+ const SINGLE_VALUE_HEADERS: Record<string, string> = {
48
+ "x-frame-options": "X-Frame-Options",
49
+ "x-content-type-options": "X-Content-Type-Options",
50
+ "strict-transport-security": "Strict-Transport-Security",
51
+ "cross-origin-opener-policy": "Cross-Origin-Opener-Policy",
52
+ "cross-origin-resource-policy": "Cross-Origin-Resource-Policy",
53
+ "cross-origin-embedder-policy": "Cross-Origin-Embedder-Policy",
54
+ "x-xss-protection": "X-XSS-Protection",
55
+ };
56
+
57
+ /**
58
+ * CSP is its own case: a comma separates *whole policies*, and a browser
59
+ * enforces every one of them — the effective policy is their intersection. Two
60
+ * policies that were each written to be sufficient usually intersect into
61
+ * something that blocks the page.
62
+ */
63
+ const CSP_HEADERS: Record<string, string> = {
64
+ "content-security-policy": "Content-Security-Policy",
65
+ "content-security-policy-report-only": "Content-Security-Policy-Report-Only",
66
+ };
67
+
68
+ /** Split a folded header value into the values that were actually sent. */
69
+ export function splitFolded(value: string): string[] {
70
+ return value
71
+ .split(",")
72
+ .map((part) => part.trim())
73
+ .filter((part) => part.length > 0);
74
+ }
75
+
76
+ /**
77
+ * Inspect a set of response headers for duplicates.
78
+ *
79
+ * Exported separately from {@link probeHeaders} so the analysis can be tested
80
+ * without a network round-trip — the fetch is the only part that needs one.
81
+ */
82
+ export function analyseHeaders(url: string, headers: Headers): HeaderProbeResult[] {
83
+ const findings: HeaderProbeResult[] = [];
84
+
85
+ for (const [key, label] of Object.entries(SINGLE_VALUE_HEADERS)) {
86
+ const raw = headers.get(key);
87
+ if (raw === null) continue;
88
+ const values = splitFolded(raw);
89
+ if (values.length < 2) continue;
90
+
91
+ const distinct = [...new Set(values.map((value) => value.toLowerCase()))];
92
+ if (distinct.length > 1) {
93
+ findings.push({
94
+ url,
95
+ header: label,
96
+ values,
97
+ ok: false,
98
+ conflicting: true,
99
+ message:
100
+ `sent ${values.length} times with different values (${values.join(" / ")}). ` +
101
+ `Browsers do not agree on which one applies, so this control is enforced ` +
102
+ `inconsistently across your visitors.`,
103
+ fix:
104
+ `Set ${label} in exactly one place — either the app (config/app.ts → ` +
105
+ `app.secureHeaders) or the proxy — and remove the other.`,
106
+ });
107
+ continue;
108
+ }
109
+
110
+ findings.push({
111
+ url,
112
+ header: label,
113
+ values,
114
+ ok: false,
115
+ conflicting: false,
116
+ message:
117
+ `sent ${values.length} times with the same value (${values[0]}). Harmless today, ` +
118
+ `and a conflict the moment either side is changed without the other.`,
119
+ fix: `Remove the duplicate — keep ${label} in one place.`,
120
+ });
121
+ }
122
+
123
+ for (const [key, label] of Object.entries(CSP_HEADERS)) {
124
+ const raw = headers.get(key);
125
+ if (raw === null) continue;
126
+ // A comma inside a policy is not valid in the directives apps actually use,
127
+ // so one here means a second policy was appended.
128
+ const policies = splitFolded(raw);
129
+ if (policies.length < 2) continue;
130
+
131
+ findings.push({
132
+ url,
133
+ header: label,
134
+ values: policies,
135
+ ok: false,
136
+ conflicting: true,
137
+ message:
138
+ `${policies.length} separate policies were sent. A browser enforces all of them at ` +
139
+ `once, so the policy in force is their intersection — usually stricter than either ` +
140
+ `author intended, and a page that breaks for no visible reason.`,
141
+ fix: `Send one ${label}, from the app or the proxy but not both.`,
142
+ });
143
+ }
144
+
145
+ return findings;
146
+ }
147
+
148
+ /**
149
+ * Fetch `url` and report every duplicated security header on the response.
150
+ *
151
+ * Returns an empty array when the request fails: an unreachable URL is the
152
+ * transport probe's finding to make, and reporting it twice would be noise.
153
+ */
154
+ export async function probeHeaders(url: string): Promise<HeaderProbeResult[]> {
155
+ let response: Response;
156
+ try {
157
+ // `redirect: "manual"` on purpose: a redirect's own headers are what the
158
+ // proxy adds, and following it would report the destination's instead.
159
+ response = await fetch(url, { method: "GET", redirect: "manual" });
160
+ } catch {
161
+ return [];
162
+ }
163
+ return analyseHeaders(url, response.headers);
164
+ }
@@ -270,6 +270,30 @@ export class Emitter {
270
270
  return (this._listeners.get(eventClass)?.length ?? 0) > 0;
271
271
  }
272
272
 
273
+ /**
274
+ * Every event with a listener, and the listeners it has, by name.
275
+ *
276
+ * The wiring between an application's events and what reacts to them is
277
+ * spread across every provider that calls `listen()`, so "what happens when an
278
+ * order is placed" is a question you answer by searching. This is that map,
279
+ * and it is what the inspector's Events tab draws.
280
+ *
281
+ * Names rather than classes, because the answer is read by a human or crosses
282
+ * a wire — and a listener class is not serialisable either way.
283
+ *
284
+ * @returns One row per event with at least one listener, sorted by name.
285
+ * @category Subscription
286
+ */
287
+ registrations(): Array<{ event: string; listeners: string[] }> {
288
+ return [...this._listeners.entries()]
289
+ .filter(([, listeners]) => listeners.length > 0)
290
+ .map(([eventClass, listeners]) => ({
291
+ event: (eventClass as { name?: string }).name ?? String(eventClass),
292
+ listeners: listeners.map((listener) => listener.name),
293
+ }))
294
+ .sort((a, b) => a.event.localeCompare(b.event));
295
+ }
296
+
273
297
  /**
274
298
  * Remove every registered listener.
275
299
  * @category Subscription
@@ -133,6 +133,34 @@ export const FrameworkEvents = {
133
133
  for (const handlers of _byKind.values()) count += handlers.size;
134
134
  return count;
135
135
  },
136
+
137
+ /**
138
+ * Which events currently have subscribers, and how many each has.
139
+ *
140
+ * The bus is the framework's nervous system and has been invisible: "does
141
+ * anything actually listen to `ModelChanged`" was a question you answered by
142
+ * reading every package. Sorted by name so two calls are comparable.
143
+ *
144
+ * Class- and kind-keyed subscriptions are merged, because a subscriber that
145
+ * listened by string and one that imported the class are subscribed to the
146
+ * same event and a reader does not care which door they came through.
147
+ *
148
+ * @returns One row per event with at least one live handler.
149
+ * @category Subscription
150
+ */
151
+ subscriptions(): Array<{ event: string; handlers: number }> {
152
+ const counts = new Map<string, number>();
153
+ for (const [ctor, handlers] of _byClass) {
154
+ if (handlers.size)
155
+ counts.set(_kindOf(ctor), (counts.get(_kindOf(ctor)) ?? 0) + handlers.size);
156
+ }
157
+ for (const [kind, handlers] of _byKind) {
158
+ if (handlers.size) counts.set(kind, (counts.get(kind) ?? 0) + handlers.size);
159
+ }
160
+ return [...counts.entries()]
161
+ .map(([event, handlers]) => ({ event, handlers }))
162
+ .sort((a, b) => a.event.localeCompare(b.event));
163
+ },
136
164
  };
137
165
 
138
166
  // ── Framework event types ─────────────────────────────────────────────────────
@@ -218,6 +246,20 @@ export class RequestFailed {
218
246
  readonly durationMs: number,
219
247
  readonly error: string,
220
248
  readonly status: number,
249
+ /**
250
+ * The error's class name, when the failure was an `Error`.
251
+ *
252
+ * `message` alone cannot tell a `ValidationError` from a `TypeError`, and
253
+ * which one it was is usually the first thing you want to know.
254
+ */
255
+ readonly type?: string,
256
+ /**
257
+ * The raw `Error.stack`, for subscribers that render a trace.
258
+ *
259
+ * Carried as the unparsed string: the shape differs between runtimes, and
260
+ * this event should not be the thing that decides how a frame is spelled.
261
+ */
262
+ readonly stack?: string,
221
263
  ) {}
222
264
  }
223
265
 
package/src/index.ts CHANGED
@@ -66,6 +66,7 @@ export type {
66
66
  FileRoutingEntry,
67
67
  FileRoutingConfig,
68
68
  AppScopeInstaller,
69
+ ProviderReport,
69
70
  } from "./application/Application.ts";
70
71
  export { ExceptionHandler } from "./application/ExceptionHandler.ts";
71
72
 
@@ -161,6 +162,7 @@ export {
161
162
  export { config } from "./helpers/config.ts";
162
163
  export { pluralize, singularize, snakeCase, camelCase, tableNameFor } from "./support/str.ts";
163
164
  export { deepMerge } from "./support/deepMerge.ts";
165
+ export type { DeepPartial } from "./support/deepMerge.ts";
164
166
  // The type every class-keyed registry uses — a class rather than an instance.
165
167
  export type { ClassRef } from "./support/classRef.ts";
166
168
  export { safeEqual, sha256Hex, hmacHex } from "./support/crypto.ts";
@@ -8,6 +8,7 @@ import type { HttpContext } from "../pipeline/HttpContext.ts";
8
8
  // Canonical implementation now lives in support/deepMerge.ts; re-exported here so existing
9
9
  // `import { deepMerge } from "./BaseMiddleware.ts"` call sites keep working.
10
10
  import { deepMerge } from "../support/deepMerge.ts";
11
+ import type { DeepPartial } from "../support/deepMerge.ts";
11
12
 
12
13
  export { deepMerge };
13
14
 
@@ -44,13 +45,23 @@ export abstract class BaseMiddleware<O extends object = object> implements Pipe<
44
45
  /**
45
46
  * Returns a zero-arg subclass with the given options deep-merged on top of
46
47
  * the subclass defaults, usable directly in app.use([...]).
48
+ *
49
+ * `NoInfer` on the parameter is what makes this type-check at all. `Opts` has
50
+ * a default computed from the middleware class, but a type parameter that
51
+ * appears in an argument position is inferred from the *argument* first and
52
+ * only falls back to its default when inference finds nothing — so
53
+ * `Middleware.with({ resolve: (claims) => … })` used to infer `Opts` from the
54
+ * object literal it was handed, which meant the literal type-checked against
55
+ * itself. Every callback parameter arrived implicitly `any`, and a misspelled
56
+ * option was accepted in silence. Blocking inference makes the middleware's
57
+ * own option type the one that governs.
47
58
  */
48
59
  static with<
49
60
  // 1. Constrain T to be a concrete class (not abstract) that extends BaseMiddleware
50
61
  T extends new (...args: any[]) => BaseMiddleware<any>,
51
62
  // 2. Dynamically infer the specific options type (U) from that concrete class
52
63
  Opts = T extends new (...args: any[]) => BaseMiddleware<infer U> ? U : object,
53
- >(this: T, options: Partial<Opts>): new () => InstanceType<T> {
64
+ >(this: T, options: DeepPartial<NoInfer<Opts>>): new () => InstanceType<T> {
54
65
  const configured = class extends (this as any) {
55
66
  constructor() {
56
67
  super();