@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/CHANGELOG.md +74 -1
- package/api-surface.md +3609 -0
- package/package.json +2 -1
- package/src/application/Application.ts +121 -8
- package/src/command/builtin/DoctorCommand.ts +53 -6
- package/src/doctor/HeaderProbe.ts +164 -0
- package/src/events/Emitter.ts +24 -0
- package/src/events/FrameworkEvents.ts +42 -0
- package/src/index.ts +2 -0
- package/src/middleware/BaseMiddleware.ts +12 -1
- package/src/middleware/SecureHeadersMiddleware.ts +54 -23
- package/src/provider/StorageProvider.ts +4 -1
- package/src/router/RouteHandler.ts +5 -0
- package/src/router/Router.ts +8 -2
- package/src/security/index.ts +6 -0
- package/src/security/redactGraph.ts +98 -0
- package/src/support/deepMerge.ts +42 -2
|
@@ -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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
124
|
+
const frameOptions = options.frameOptions ?? "SAMEORIGIN";
|
|
125
|
+
if (frameOptions) secure["X-Frame-Options"] = frameOptions;
|
|
106
126
|
|
|
107
|
-
|
|
108
|
-
|
|
127
|
+
const permissionsPolicy = options.permissionsPolicy ?? DEFAULT_PERMISSIONS_POLICY;
|
|
128
|
+
if (permissionsPolicy) secure["Permissions-Policy"] = permissionsPolicy;
|
|
109
129
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
130
|
+
if (options.contentSecurityPolicy) {
|
|
131
|
+
secure["Content-Security-Policy"] = options.contentSecurityPolicy;
|
|
132
|
+
}
|
|
113
133
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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 {
|
package/src/router/Router.ts
CHANGED
|
@@ -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
|
-
|
|
1042
|
+
{ headers },
|
|
1037
1043
|
);
|
|
1038
1044
|
registered++;
|
|
1039
1045
|
}
|
package/src/security/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/support/deepMerge.ts
CHANGED
|
@@ -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:
|
|
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
|
|
150
|
+
overrideValue as DeepPartial<typeof baseValue>,
|
|
111
151
|
);
|
|
112
152
|
} else {
|
|
113
153
|
(result as Record<string, unknown>)[key] = _clone(overrideValue);
|