@zerotal/core 1.6.2 → 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.
@@ -94,34 +94,65 @@ export class SecureHeadersMiddleware extends BaseMiddleware<SecureHeadersOptions
94
94
  async handle(_ctx: HttpContext, next: NextFn): Promise<Response | void> {
95
95
  const response = await next();
96
96
  if (!response) return;
97
+ return withHeaders(response, securityHeaders(this.options));
98
+ }
99
+ }
97
100
 
98
- const secure: Record<string, string> = {
99
- // Always-on security headers
100
- "X-Content-Type-Options": "nosniff",
101
- "Referrer-Policy": this.options.referrerPolicy ?? "strict-origin-when-cross-origin",
102
- };
101
+ /**
102
+ * The headers this middleware adds, as a plain object.
103
+ *
104
+ * Separated from `handle()` because the middleware is not the only thing that
105
+ * has to send them. Static files are registered with Bun as native
106
+ * `Response(Bun.file)` routes and are answered without ever entering JavaScript,
107
+ * so the pipeline — and therefore this middleware — never runs for them. That
108
+ * left `/css/app.css` served with no `X-Content-Type-Options: nosniff`, which is
109
+ * precisely the kind of response sniffing protection exists for, while the
110
+ * framework advertised the header as automatic. {@link Router.static} calls this
111
+ * so the same set is attached at registration time and Bun still serves the file
112
+ * natively.
113
+ *
114
+ * @param options - Resolved secure-header options, usually `app.secureHeaders`.
115
+ * @returns Header name → value. Never includes HSTS unless `secure` is set.
116
+ */
117
+ export function securityHeaders(options: SecureHeadersOptions): Record<string, string> {
118
+ const secure: Record<string, string> = {
119
+ // Always-on security headers
120
+ "X-Content-Type-Options": "nosniff",
121
+ "Referrer-Policy": options.referrerPolicy ?? "strict-origin-when-cross-origin",
122
+ };
103
123
 
104
- const frameOptions = this.options.frameOptions ?? "SAMEORIGIN";
105
- if (frameOptions) secure["X-Frame-Options"] = frameOptions;
124
+ const frameOptions = options.frameOptions ?? "SAMEORIGIN";
125
+ if (frameOptions) secure["X-Frame-Options"] = frameOptions;
106
126
 
107
- const permissionsPolicy = this.options.permissionsPolicy ?? DEFAULT_PERMISSIONS_POLICY;
108
- if (permissionsPolicy) secure["Permissions-Policy"] = permissionsPolicy;
127
+ const permissionsPolicy = options.permissionsPolicy ?? DEFAULT_PERMISSIONS_POLICY;
128
+ if (permissionsPolicy) secure["Permissions-Policy"] = permissionsPolicy;
109
129
 
110
- if (this.options.contentSecurityPolicy) {
111
- secure["Content-Security-Policy"] = this.options.contentSecurityPolicy;
112
- }
130
+ if (options.contentSecurityPolicy) {
131
+ secure["Content-Security-Policy"] = options.contentSecurityPolicy;
132
+ }
113
133
 
114
- // HSTS only over HTTPS — emitting it on HTTP can lock users out
115
- if (this.options.secure) {
116
- const maxAge = this.options.hstsMaxAge ?? 31_536_000;
117
- if (maxAge > 0) {
118
- const parts = [`max-age=${maxAge}`];
119
- if (this.options.hstsIncludeSubDomains !== false) parts.push("includeSubDomains");
120
- if (this.options.hstsPreload) parts.push("preload");
121
- secure["Strict-Transport-Security"] = parts.join("; ");
122
- }
134
+ // HSTS only over HTTPS — emitting it on HTTP can lock users out
135
+ if (options.secure) {
136
+ const maxAge = options.hstsMaxAge ?? 31_536_000;
137
+ if (maxAge > 0) {
138
+ const parts = [`max-age=${maxAge}`];
139
+ if (options.hstsIncludeSubDomains !== false) parts.push("includeSubDomains");
140
+ if (options.hstsPreload) parts.push("preload");
141
+ secure["Strict-Transport-Security"] = parts.join("; ");
123
142
  }
124
-
125
- return withHeaders(response, secure);
126
143
  }
144
+
145
+ return secure;
146
+ }
147
+
148
+ /**
149
+ * The security headers a static file should carry, read from `app.secureHeaders`.
150
+ *
151
+ * A separate entry point from {@link securityHeaders} so the caller does not
152
+ * have to reach for the config facade itself, and so the fallback is stated in
153
+ * one place: an app with no `app.secureHeaders` block still gets the baseline,
154
+ * which is the whole point of the defaults.
155
+ */
156
+ export function staticSecurityHeaders(): Record<string, string> {
157
+ return securityHeaders(config.safe("app.secureHeaders", {} as SecureHeadersOptions));
127
158
  }
@@ -32,7 +32,10 @@ export class StorageProvider extends ServiceProvider {
32
32
  }
33
33
 
34
34
  override async onBooting(): Promise<void> {
35
- const storage = await this.app.container.make("storage");
35
+ // Named explicitly: `make()` infers its type parameter from the token, and a
36
+ // plain string token infers nothing — so this used to arrive as `unknown`
37
+ // and reach `StorageFilesMiddleware.with({ storage })` unchecked.
38
+ const storage = await this.app.container.make<StorageManager>("storage");
36
39
 
37
40
  const config = this.app.container.makeSync("config") as ConfigManager;
38
41
  const storageCfg = config.get<ReturnType<typeof StorageConfig>>("storage", StorageConfig());
@@ -172,6 +172,11 @@ export async function dispatchRequest(
172
172
  durationMs,
173
173
  failure instanceof Error ? failure.message : String(failure),
174
174
  response!.status,
175
+ // The class name and the stack alongside the message. A subscriber
176
+ // rendering a failure has nothing to show without them, and by the
177
+ // time the event is emitted the error is the only place they exist.
178
+ failure instanceof Error ? failure.name : undefined,
179
+ failure instanceof Error ? failure.stack : undefined,
175
180
  ),
176
181
  );
177
182
  } else {
@@ -30,6 +30,7 @@ import { compileDomain, matchDomain, setRequestSubdomains } from "./domain.ts";
30
30
  import type { ProviderHooks } from "./RouteHandler.ts";
31
31
  import { tryCurrentApp } from "../application/currentApp.ts";
32
32
  import { frameworkLog } from "../logger/frameworkLog.ts";
33
+ import { staticSecurityHeaders } from "../middleware/SecureHeadersMiddleware.ts";
33
34
  import {
34
35
  markdownExtractTitle,
35
36
  markdownPage,
@@ -1026,14 +1027,19 @@ export class Router {
1026
1027
  continue;
1027
1028
  }
1028
1029
  const basePrefix = prefix.replace(/\/$/, "");
1030
+ // Static files are handed to Bun as native `Response(Bun.file)` routes and
1031
+ // answered without entering JavaScript, so `SecureHeadersMiddleware` — and
1032
+ // the whole pipeline — never runs for them. The headers are therefore
1033
+ // baked in here instead. Anything the dir declared wins: a caller who set
1034
+ // an explicit `X-Frame-Options` for one mount meant it.
1035
+ const headers = { ...staticSecurityHeaders(), ...options?.headers };
1029
1036
  let registered = 0;
1030
1037
  for (const relativePath of files) {
1031
1038
  const urlPath = `${basePrefix}/${relativePath.replace(/\\/g, "/")}`.replace(/\/+/g, "/");
1032
1039
  if (compiled[urlPath]) continue;
1033
- const headers = options?.headers;
1034
1040
  compiled[urlPath] = new Response(
1035
1041
  Bun.file(`${rootDir}/${relativePath}`) as unknown as BodyInit,
1036
- headers ? { headers } : undefined,
1042
+ { headers },
1037
1043
  );
1038
1044
  registered++;
1039
1045
  }
@@ -26,3 +26,9 @@ export type { HashAlgorithm } from "../hash/Hash.ts";
26
26
  // signing with no secret to manage, `Url.sign` / `Url.verify` on the http subpath is the
27
27
  // higher-level option.
28
28
  export { URLSigner } from "../crypt/URLSigner.ts";
29
+ // The redaction walk every recorder needs and each had written for itself. Not a
30
+ // policy — callers bring their own markers and their own sensitivity predicate,
31
+ // because an adapter implementing a published protocol does not get to choose
32
+ // its markers and a debug panel does.
33
+ export { redactGraph } from "./redactGraph.ts";
34
+ export type { RedactGraphOptions } from "./redactGraph.ts";
@@ -0,0 +1,98 @@
1
+ /**
2
+ * One object-graph redaction walk, shared by everything that records a value it
3
+ * did not choose.
4
+ *
5
+ * Several packages need the same thing: copy a value, replace what a key name
6
+ * says is a secret, and come back with something `JSON.stringify` survives. Each
7
+ * had written its own — and each had to solve the same three hazards, which are
8
+ * the parts that are easy to get subtly wrong:
9
+ *
10
+ * - **Cycles.** A model holding a back-reference to its parent is ordinary, and
11
+ * `JSON.stringify` throws on it. The ancestor set is released on the way back
12
+ * *up*, so a value that legitimately appears twice as a sibling is rendered
13
+ * twice rather than reported as a cycle the second time.
14
+ * - **Depth.** Recording happens on the request path, so a pathological graph
15
+ * must not stall it.
16
+ * - **Values that read better flat than walked.** A `Date`, a `File`, an `Error`
17
+ * — `Object.entries` on any of them produces something worse than useless.
18
+ *
19
+ * What it deliberately does *not* fix is the vocabulary. Callers bring their own
20
+ * markers and their own sensitivity predicate, because those are not
21
+ * interchangeable: a devtools panel's `‹redacted›` is a display choice, while an
22
+ * adapter implementing a published wire protocol has its markers specified for
23
+ * it. Sharing the walk does not mean agreeing on the words.
24
+ */
25
+
26
+ /** How one caller wants the graph walked. */
27
+ export interface RedactGraphOptions {
28
+ /** Whether the value under this key should be withheld. */
29
+ sensitive: (key: string) => boolean;
30
+ /** What a withheld value is replaced with. */
31
+ mask: string;
32
+ /** What a reference back to an ancestor is replaced with. */
33
+ circular: string;
34
+ /** What a value at or below the depth limit is replaced with. */
35
+ tooDeep: string;
36
+ /**
37
+ * The depth at which the walk stops. The root is depth 0, so `6` renders five
38
+ * levels of nesting and replaces the sixth.
39
+ */
40
+ maxDepth: number;
41
+ /**
42
+ * Render a value instead of walking into it — a `Date` as an ISO string, a
43
+ * `File` as a summary. Return `undefined` to walk it normally.
44
+ *
45
+ * Called for every non-null value, before the cycle and depth checks, so a
46
+ * flattened value is never reported as either — and so a caller can also name
47
+ * things that are not objects at all. A function is the case worth having:
48
+ * `JSON.stringify` drops the key it sits under, and a debugging tool that
49
+ * silently omits a field is worse than one that says `‹fn›`.
50
+ */
51
+ flatten?: (value: unknown) => string | undefined;
52
+ }
53
+
54
+ /**
55
+ * Copy `value`, masking every field whose name `options.sensitive` rejects.
56
+ *
57
+ * A primitive is returned unchanged — there is no key to judge it by, and
58
+ * inspecting a string's *contents* for things that look like secrets is a
59
+ * different, guessier job this does not attempt.
60
+ *
61
+ * @param value - Anything. Not modified.
62
+ * @param options - Markers, limits, and the sensitivity predicate.
63
+ * @returns A new value, safe to serialise.
64
+ */
65
+ export function redactGraph(value: unknown, options: RedactGraphOptions): unknown {
66
+ return _walk(value, options, 0, new WeakSet());
67
+ }
68
+
69
+ function _walk(
70
+ value: unknown,
71
+ options: RedactGraphOptions,
72
+ depth: number,
73
+ seen: WeakSet<object>,
74
+ ): unknown {
75
+ if (value === null) return null;
76
+
77
+ const flat = options.flatten?.(value);
78
+ if (flat !== undefined) return flat;
79
+ if (typeof value !== "object") return value;
80
+
81
+ const object = value as object;
82
+ if (seen.has(object)) return options.circular;
83
+ if (depth >= options.maxDepth) return options.tooDeep;
84
+
85
+ seen.add(object);
86
+ try {
87
+ if (Array.isArray(object)) {
88
+ return object.map((item) => _walk(item, options, depth + 1, seen));
89
+ }
90
+ const out: Record<string, unknown> = {};
91
+ for (const [key, item] of Object.entries(object as Record<string, unknown>)) {
92
+ out[key] = options.sensitive(key) ? options.mask : _walk(item, options, depth + 1, seen);
93
+ }
94
+ return out;
95
+ } finally {
96
+ seen.delete(object);
97
+ }
98
+ }
@@ -44,6 +44,46 @@
44
44
  /** Keys that must never be copied across — they can pollute `Object.prototype`. */
45
45
  const _UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
46
46
 
47
+ /**
48
+ * Values {@link deepMerge} treats as atomic: replaced wholesale, never recursed
49
+ * into. Kept in step with `_isPlainObject` below — a type that recursed into an
50
+ * array would ask callers for `{ 0?: string }` where the function wants
51
+ * `string[]`.
52
+ */
53
+ type _Atomic =
54
+ | readonly unknown[]
55
+ | Date
56
+ | RegExp
57
+ | Map<unknown, unknown>
58
+ | Set<unknown>
59
+ | ((...args: never[]) => unknown);
60
+
61
+ /**
62
+ * Every key optional, all the way down — the shape {@link deepMerge} actually
63
+ * accepts.
64
+ *
65
+ * `Partial<T>` only makes the *top* level optional, so
66
+ * `{ drivers: { anthropic: { apiKey } } }` — the single most common thing anyone
67
+ * writes in a config file — was a type error against a shape whose `anthropic`
68
+ * has other keys, even though the merge handles it perfectly. `@zerotal/ai` hit
69
+ * this first and defined its own copy; this is that type, promoted so nothing
70
+ * has to define it again.
71
+ *
72
+ * The explicit `| undefined` is deliberate under `exactOptionalPropertyTypes`:
73
+ * `deepMerge` documents that an `undefined` override is skipped rather than
74
+ * blanking a default, so passing one explicitly has a defined meaning and
75
+ * should type-check.
76
+ */
77
+ export type DeepPartial<T> = {
78
+ [K in keyof T]?:
79
+ | (NonNullable<T[K]> extends _Atomic
80
+ ? T[K]
81
+ : NonNullable<T[K]> extends object
82
+ ? DeepPartial<NonNullable<T[K]>>
83
+ : T[K])
84
+ | undefined;
85
+ };
86
+
47
87
  /**
48
88
  * A *plain* object: a `{}`-style record whose prototype is `Object.prototype` or
49
89
  * `null`. Class instances, arrays, Dates, Maps, etc. are intentionally excluded so
@@ -96,7 +136,7 @@ function _clone<T>(value: T): T {
96
136
  * deepMerge({ tags: ["a", "b"] }, { tags: ["c"] });
97
137
  * // → { tags: ["c"] }
98
138
  */
99
- export function deepMerge<T extends object>(base: T, override: Partial<T>): T {
139
+ export function deepMerge<T extends object>(base: T, override: DeepPartial<T>): T {
100
140
  const result = _clone(base) as T;
101
141
  for (const key in override) {
102
142
  if (!Object.prototype.hasOwnProperty.call(override, key)) continue;
@@ -107,7 +147,7 @@ export function deepMerge<T extends object>(base: T, override: Partial<T>): T {
107
147
  if (_isPlainObject(overrideValue) && _isPlainObject(baseValue)) {
108
148
  (result as Record<string, unknown>)[key] = deepMerge(
109
149
  baseValue,
110
- overrideValue as Partial<typeof baseValue>,
150
+ overrideValue as DeepPartial<typeof baseValue>,
111
151
  );
112
152
  } else {
113
153
  (result as Record<string, unknown>)[key] = _clone(overrideValue);
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The framework version this build of `@zerotal/core` is.
3
+ *
4
+ * Read from the manifest rather than written down, for the reason `@zerotal/monitor`
5
+ * already learned: a hardcoded version is correct until the next release, and three
6
+ * separate literals across the monorepo once claimed a version that had never been
7
+ * published. The monorepo releases in lockstep, so this package's manifest carries
8
+ * the framework version, and it ships inside the tarball.
9
+ *
10
+ * @module
11
+ */
12
+ import { readFileSync } from "node:fs";
13
+
14
+ const pkg: { version: string } = JSON.parse(
15
+ readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
16
+ ) as { version: string };
17
+
18
+ /** Full version of the running framework, e.g. `1.6.2`. */
19
+ export const ZEROTAL_VERSION: string = pkg.version;
20
+
21
+ /**
22
+ * The version of `@zerotal/core` currently installed in a project, which is not
23
+ * necessarily {@link ZEROTAL_VERSION} — a long-running process holds the code it
24
+ * booted with, so an upgrade lands on disk without reaching it.
25
+ *
26
+ * @param cwd - Project root to look under.
27
+ * @returns The installed version, or `null` when there is nothing to read — a
28
+ * workspace checkout, a hoisted layout, a partial install. Absence is not a
29
+ * finding, so callers stay quiet on `null` rather than guessing.
30
+ */
31
+ export function installedCoreVersion(cwd: string): string | null {
32
+ try {
33
+ const raw = readFileSync(`${cwd}/node_modules/@zerotal/core/package.json`, "utf8");
34
+ const { version } = JSON.parse(raw) as { version?: string };
35
+ return version ?? null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }