@zerotal/core 1.6.3 → 1.7.2
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 +176 -0
- package/api-surface.md +3609 -0
- package/package.json +3 -1
- package/src/application/Application.ts +174 -11
- package/src/command/builtin/DoctorCommand.ts +53 -6
- package/src/command/builtin/RouteTypesCommand.ts +1 -0
- package/src/dev/DevDeck.ts +144 -20
- package/src/dev/DevOrchestrator.ts +1 -1
- package/src/doctor/HeaderProbe.ts +164 -0
- package/src/events/Emitter.ts +24 -0
- package/src/events/FrameworkEvents.ts +42 -0
- package/src/helpers/index.ts +43 -28
- 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 +62 -3
- package/src/router/routeTypes.ts +52 -11
- package/src/router/routes.ts +115 -0
- package/src/security/index.ts +6 -0
- package/src/security/redactGraph.ts +107 -0
- package/src/support/deepMerge.ts +42 -2
- package/src/support/env.ts +48 -8
|
@@ -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:
|
|
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();
|
|
@@ -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,
|
|
@@ -80,6 +81,40 @@ function _wrapFileHandler(fn: FileHandler, debugName: string): ControllerClass {
|
|
|
80
81
|
type AnyRouteHandler = RouteHandler<any>;
|
|
81
82
|
|
|
82
83
|
type RouteHandlerFn = (req: Request, server?: unknown) => Response | Promise<Response>;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Wrap a raw handler so its response carries the security headers, without
|
|
87
|
+
* touching any the handler set for itself.
|
|
88
|
+
*
|
|
89
|
+
* Add-if-absent rather than `withHeaders`, which overwrites: a raw route is the
|
|
90
|
+
* one place a handler is fully in charge of its own response, and a transport
|
|
91
|
+
* endpoint that deliberately sets `X-Frame-Options` for its own reasons must
|
|
92
|
+
* keep it. The response is only reconstructed when something is actually
|
|
93
|
+
* missing, so a handler that already set everything pays nothing — and
|
|
94
|
+
* reconstruction is required rather than optional when it happens, because a
|
|
95
|
+
* `Response.redirect()` has an immutable headers guard that throws on `set`.
|
|
96
|
+
*/
|
|
97
|
+
function _withSecurityDefaults(
|
|
98
|
+
handler: RouteHandlerFn,
|
|
99
|
+
defaults: Record<string, string>,
|
|
100
|
+
): RouteHandlerFn {
|
|
101
|
+
const names = Object.keys(defaults);
|
|
102
|
+
if (names.length === 0) return handler;
|
|
103
|
+
|
|
104
|
+
return async (req, server) => {
|
|
105
|
+
const response = await handler(req, server);
|
|
106
|
+
const missing = names.filter((name) => !response.headers.has(name));
|
|
107
|
+
if (missing.length === 0) return response;
|
|
108
|
+
|
|
109
|
+
const merged = new Headers(response.headers);
|
|
110
|
+
for (const name of missing) merged.set(name, defaults[name]!);
|
|
111
|
+
return new Response(response.body, {
|
|
112
|
+
status: response.status,
|
|
113
|
+
statusText: response.statusText,
|
|
114
|
+
headers: merged,
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
}
|
|
83
118
|
/**
|
|
84
119
|
* A path entry is either a method-keyed map of handlers, or a bare static
|
|
85
120
|
* `Response` (Bun.serve serves the latter at zero JS cost per request).
|
|
@@ -1026,14 +1061,19 @@ export class Router {
|
|
|
1026
1061
|
continue;
|
|
1027
1062
|
}
|
|
1028
1063
|
const basePrefix = prefix.replace(/\/$/, "");
|
|
1064
|
+
// Static files are handed to Bun as native `Response(Bun.file)` routes and
|
|
1065
|
+
// answered without entering JavaScript, so `SecureHeadersMiddleware` — and
|
|
1066
|
+
// the whole pipeline — never runs for them. The headers are therefore
|
|
1067
|
+
// baked in here instead. Anything the dir declared wins: a caller who set
|
|
1068
|
+
// an explicit `X-Frame-Options` for one mount meant it.
|
|
1069
|
+
const headers = { ...staticSecurityHeaders(), ...options?.headers };
|
|
1029
1070
|
let registered = 0;
|
|
1030
1071
|
for (const relativePath of files) {
|
|
1031
1072
|
const urlPath = `${basePrefix}/${relativePath.replace(/\\/g, "/")}`.replace(/\/+/g, "/");
|
|
1032
1073
|
if (compiled[urlPath]) continue;
|
|
1033
|
-
const headers = options?.headers;
|
|
1034
1074
|
compiled[urlPath] = new Response(
|
|
1035
1075
|
Bun.file(`${rootDir}/${relativePath}`) as unknown as BodyInit,
|
|
1036
|
-
|
|
1076
|
+
{ headers },
|
|
1037
1077
|
);
|
|
1038
1078
|
registered++;
|
|
1039
1079
|
}
|
|
@@ -1071,12 +1111,31 @@ export class Router {
|
|
|
1071
1111
|
|
|
1072
1112
|
// Raw routes bypass the middleware pipeline entirely — added last so they
|
|
1073
1113
|
// take precedence over any same-path pipeline routes.
|
|
1114
|
+
//
|
|
1115
|
+
// Bypassing the pipeline also bypasses `SecureHeadersMiddleware`, and that is
|
|
1116
|
+
// not what anyone opts out for: `Router.raw()` exists to skip *request*
|
|
1117
|
+
// handling — CSRF on a transport endpoint, session resolution on a relay —
|
|
1118
|
+
// not to opt a response out of the headers the framework advertises as
|
|
1119
|
+
// automatic. This framework's own documentation site serves every `/docs/*`
|
|
1120
|
+
// page from a raw route, and every one of them went out with no
|
|
1121
|
+
// `X-Content-Type-Options: nosniff`.
|
|
1122
|
+
//
|
|
1123
|
+
// Computed once here rather than per request: raw routes include Flow's
|
|
1124
|
+
// action endpoint, which is as hot as anything in the app.
|
|
1125
|
+
const rawDefaults = staticSecurityHeaders();
|
|
1074
1126
|
for (const [key, handler] of _s().rawRoutes) {
|
|
1075
1127
|
const spaceIndex = key.indexOf(" ");
|
|
1076
1128
|
const method = key.slice(0, spaceIndex) as HttpMethod;
|
|
1077
1129
|
const path = key.slice(spaceIndex + 1);
|
|
1078
1130
|
const rawMap = (compiled[path] ??= {}) as Record<string, RouteHandlerFn>;
|
|
1079
|
-
rawMap[method] = handler;
|
|
1131
|
+
rawMap[method] = _withSecurityDefaults(handler, rawDefaults);
|
|
1132
|
+
// And `HEAD`, for the same reason the pipeline derives it — a raw route is
|
|
1133
|
+
// still a route, and `curl -I` against one answered 404 while the `GET`
|
|
1134
|
+
// beside it answered 200. This site's own `/docs/*` and `/blog` are raw,
|
|
1135
|
+
// so every link checker and uptime probe pointed at them was told the page
|
|
1136
|
+
// did not exist. Derived from the wrapped handler, so the headers above
|
|
1137
|
+
// ride along.
|
|
1138
|
+
if (method === "GET") rawMap["HEAD"] ??= _headFrom(rawMap[method]!);
|
|
1080
1139
|
}
|
|
1081
1140
|
|
|
1082
1141
|
return compiled;
|
package/src/router/routeTypes.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* the browser bundle.
|
|
25
25
|
*/
|
|
26
26
|
import { relative } from "node:path";
|
|
27
|
+
import { Router } from "./Router.ts";
|
|
27
28
|
|
|
28
29
|
/** Where the generated map is written, relative to the project root. */
|
|
29
30
|
export const ROUTE_TYPES_FILE = "types/routes.generated.ts";
|
|
@@ -32,9 +33,11 @@ const HEADER = [
|
|
|
32
33
|
"// Auto-generated by @zerotal/core — do not edit manually.",
|
|
33
34
|
"// Regenerate with: bun zt route:types",
|
|
34
35
|
"//",
|
|
35
|
-
"// Every named route
|
|
36
|
-
"// through declaration merging, so an unknown
|
|
37
|
-
"//
|
|
36
|
+
"// Every named route, the URL pattern it compiles to, and the HTTP method it",
|
|
37
|
+
"// answers on. `route()` reads ROUTES through declaration merging, so an unknown",
|
|
38
|
+
"// name or a missing :param is a compile error; `action()` also reads METHODS, so",
|
|
39
|
+
"// a form cannot submit a route with the wrong verb.",
|
|
40
|
+
"// Commit this file: editors and CI need it without booting the app.",
|
|
38
41
|
"",
|
|
39
42
|
];
|
|
40
43
|
|
|
@@ -52,17 +55,28 @@ const HEADER = [
|
|
|
52
55
|
export function generateRouteTypes(
|
|
53
56
|
namedRoutes: ReadonlyMap<string, string>,
|
|
54
57
|
importSpecifier = "@zerotal/core",
|
|
58
|
+
methods: ReadonlyMap<string, string> = new Map(),
|
|
55
59
|
): string {
|
|
56
60
|
const entries = Array.from(namedRoutes.entries()).sort(([a], [b]) => a.localeCompare(b));
|
|
61
|
+
const key = (name: string) => (/^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name));
|
|
57
62
|
|
|
58
63
|
// Quote the key only when it isn't a bare identifier — `home` stays bare and
|
|
59
64
|
// `posts.show` stays quoted, which is what a formatter would do to this file
|
|
60
65
|
// anyway. Matching it here keeps `format:check` off a file nobody edits.
|
|
61
|
-
const lines = entries.map(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
const lines = entries.map(([name, pattern]) => ` ${key(name)}: ${JSON.stringify(pattern)},`);
|
|
67
|
+
|
|
68
|
+
// A second table rather than one of `{ url, method }` objects: `ROUTES` is what
|
|
69
|
+
// `route()` and `RouteRegistry` already read, and widening its value type would
|
|
70
|
+
// break every app that has generated this file. Two flat maps also stay
|
|
71
|
+
// tree-shakeable — a bundle that only builds links never pulls the verbs in.
|
|
72
|
+
const methodLines = entries
|
|
73
|
+
.filter(([name]) => methods.has(name))
|
|
74
|
+
.map(([name]) => ` ${key(name)}: ${JSON.stringify(methods.get(name))},`);
|
|
75
|
+
|
|
76
|
+
const methodTable =
|
|
77
|
+
methodLines.length > 0
|
|
78
|
+
? ["export const METHODS = {", ...methodLines, "} as const;"]
|
|
79
|
+
: ["export const METHODS = {} as const;"];
|
|
66
80
|
|
|
67
81
|
// Empty on one line: an app with no named routes still gets a file a formatter
|
|
68
82
|
// leaves alone (and a registry that types nothing, so `route()` stays on its
|
|
@@ -76,11 +90,17 @@ export function generateRouteTypes(
|
|
|
76
90
|
...HEADER,
|
|
77
91
|
...table,
|
|
78
92
|
"",
|
|
93
|
+
...methodTable,
|
|
94
|
+
"",
|
|
79
95
|
"/** The generated route table, as a type. */",
|
|
80
96
|
"export type Routes = typeof ROUTES;",
|
|
81
97
|
"",
|
|
98
|
+
"/** The HTTP method each named route answers on. */",
|
|
99
|
+
"export type RouteMethods = typeof METHODS;",
|
|
100
|
+
"",
|
|
82
101
|
`declare module ${JSON.stringify(importSpecifier)} {`,
|
|
83
102
|
" interface RouteRegistry extends Routes {}",
|
|
103
|
+
" interface RouteMethodRegistry extends RouteMethods {}",
|
|
84
104
|
"}",
|
|
85
105
|
// The file is rewritten on every dev boot; without a trailing newline a
|
|
86
106
|
// formatter in the app would put one back, forever.
|
|
@@ -104,15 +124,21 @@ export interface RouteTypesResult {
|
|
|
104
124
|
* Write (or, with `check`, verify) `types/routes.generated.ts` for a booted app.
|
|
105
125
|
*
|
|
106
126
|
* @param namedRoutes - The router's `namedRoutes` map.
|
|
107
|
-
* @param options - `cwd` (project root, default `process.cwd()`)
|
|
127
|
+
* @param options - `cwd` (project root, default `process.cwd()`), `check` (compare only, never write), and `methods` (name → HTTP verb).
|
|
108
128
|
* @returns Whether the on-disk file was stale, plus the contents it should have.
|
|
109
129
|
*/
|
|
110
130
|
export async function writeRouteTypes(
|
|
111
131
|
namedRoutes: ReadonlyMap<string, string>,
|
|
112
|
-
options: { cwd?: string; check?: boolean } = {},
|
|
132
|
+
options: { cwd?: string; check?: boolean; methods?: ReadonlyMap<string, string> } = {},
|
|
113
133
|
): Promise<RouteTypesResult> {
|
|
114
134
|
const cwd = options.cwd ?? process.cwd();
|
|
115
|
-
|
|
135
|
+
// Derived here rather than asked of the caller. `zt dev` regenerates this file
|
|
136
|
+
// on every boot through its own call site, and when only `route:types` passed
|
|
137
|
+
// the verbs, a dev restart silently rewrote the table empty — which turned a
|
|
138
|
+
// form POST into a GET and 404'd. A default a caller must remember is a
|
|
139
|
+
// default that eventually gets forgotten.
|
|
140
|
+
const methods = options.methods ?? routeMethods();
|
|
141
|
+
const content = generateRouteTypes(namedRoutes, "@zerotal/core", methods);
|
|
116
142
|
const target = `${cwd}/${ROUTE_TYPES_FILE}`;
|
|
117
143
|
|
|
118
144
|
const file = Bun.file(target);
|
|
@@ -130,3 +156,18 @@ export async function writeRouteTypes(
|
|
|
130
156
|
count: namedRoutes.size,
|
|
131
157
|
};
|
|
132
158
|
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Name → HTTP verb for every named route the router knows.
|
|
162
|
+
*
|
|
163
|
+
* `RouteDefinition` carries its own `name` beside `method`, so the pair comes
|
|
164
|
+
* from one record. A path join would be wrong: `GET /login` and `POST /login`
|
|
165
|
+
* share a path and differ only by name.
|
|
166
|
+
*/
|
|
167
|
+
export function routeMethods(): Map<string, string> {
|
|
168
|
+
const methods = new Map<string, string>();
|
|
169
|
+
for (const definition of Router.routes.values()) {
|
|
170
|
+
if (definition.name) methods.set(definition.name, definition.method);
|
|
171
|
+
}
|
|
172
|
+
return methods;
|
|
173
|
+
}
|
package/src/router/routes.ts
CHANGED
|
@@ -48,6 +48,31 @@ import type {
|
|
|
48
48
|
} from "./registry.ts";
|
|
49
49
|
import { buildRouteUrl, unknownRouteError } from "./buildRoute.ts";
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Re-exported so a browser bundle can write its own typed wrappers.
|
|
53
|
+
*
|
|
54
|
+
* These live in `registry.ts`, which is reachable from the `@zerotal/core` root
|
|
55
|
+
* — and that root drags the CLI command modules into any bundle that imports it.
|
|
56
|
+
* A component building a helper around `route()` needs the types without the
|
|
57
|
+
* server, so they surface here, on the entry that is already browser-safe.
|
|
58
|
+
*/
|
|
59
|
+
export type { RouteArgs, RouteParamValues, RouteQuery, RouteTarget } from "./registry.ts";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `route()` without an import.
|
|
63
|
+
*
|
|
64
|
+
* {@link defineRoutes} puts the builder on `globalThis`, and this is the
|
|
65
|
+
* declaration that lets a call site use it: a page writes `route("posts.show")`
|
|
66
|
+
* with no import line, typed exactly as the named export is — the same
|
|
67
|
+
* `RouteBuilder`, so an unknown name or a missing `:param` still fails the build.
|
|
68
|
+
*
|
|
69
|
+
* `var` rather than `const`, because only `var` in a `declare global` block
|
|
70
|
+
* creates a matching property on `globalThis` for the assignment to satisfy.
|
|
71
|
+
*/
|
|
72
|
+
declare global {
|
|
73
|
+
var route: RouteBuilder;
|
|
74
|
+
}
|
|
75
|
+
|
|
51
76
|
/**
|
|
52
77
|
* The name → pattern map `route()` resolves against. A plain object is what
|
|
53
78
|
* `types/routes.generated.ts` exports; a `Map` is accepted so a server-side
|
|
@@ -73,6 +98,25 @@ let _table: ReadonlyMap<string, string> | null = null;
|
|
|
73
98
|
*/
|
|
74
99
|
export function defineRoutes(table: RouteTable): void {
|
|
75
100
|
_table = table instanceof Map ? table : new Map(Object.entries(table));
|
|
101
|
+
_installGlobal();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Put `route()` on `globalThis`, so nothing has to import it.
|
|
106
|
+
*
|
|
107
|
+
* This is the one function both processes already call — the server from
|
|
108
|
+
* `Application._installRouteTable()` at boot, a browser entry beside its
|
|
109
|
+
* generated `ROUTES` — which makes it the only place that can install the global
|
|
110
|
+
* for both without an app remembering to do it in two files.
|
|
111
|
+
*
|
|
112
|
+
* The table is installed first, deliberately: a global that exists but throws
|
|
113
|
+
* "no route table" is worse than one that appears at the same moment it works.
|
|
114
|
+
*
|
|
115
|
+
* `route` stays a named export. Removing it would break every existing import
|
|
116
|
+
* for no gain, and a test that wants a clean global can still reach for it.
|
|
117
|
+
*/
|
|
118
|
+
function _installGlobal(): void {
|
|
119
|
+
(globalThis as { route?: typeof route }).route = route;
|
|
76
120
|
}
|
|
77
121
|
|
|
78
122
|
/**
|
|
@@ -147,3 +191,74 @@ export const route: RouteBuilder = Object.assign(
|
|
|
147
191
|
// compile time, so `import type { RouteName } from "@zerotal/core"` costs a
|
|
148
192
|
// browser bundle nothing — and a second export path for the same names is a
|
|
149
193
|
// second entry in every surface report, forever, for no runtime benefit.
|
|
194
|
+
|
|
195
|
+
// ── Verb-aware routes ─────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Augmented by `types/routes.generated.ts` with the HTTP method of every named
|
|
199
|
+
* route, exactly as {@link RouteRegistry} is augmented with their patterns.
|
|
200
|
+
*/
|
|
201
|
+
export interface RouteMethodRegistry {}
|
|
202
|
+
|
|
203
|
+
/** A name the generated table knows a verb for. */
|
|
204
|
+
export type MethodedRouteName = Extract<keyof RouteMethodRegistry, string>;
|
|
205
|
+
|
|
206
|
+
const methodTable = new Map<string, string>();
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Register the generated `METHODS` table.
|
|
210
|
+
*
|
|
211
|
+
* Called once at boot beside {@link defineRoutes}. Kept separate because the two
|
|
212
|
+
* tables have different audiences: a page that only builds links needs the
|
|
213
|
+
* patterns and never the verbs, and a bundler can then drop the verbs entirely.
|
|
214
|
+
*/
|
|
215
|
+
export function defineRouteMethods(table: Readonly<Record<string, string>>): void {
|
|
216
|
+
methodTable.clear();
|
|
217
|
+
for (const [name, method] of Object.entries(table)) methodTable.set(name, method);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The verb a named route answers on, or undefined when it was never registered. */
|
|
221
|
+
export function routeMethod(name: string): string | undefined {
|
|
222
|
+
return methodTable.get(name);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A resolved endpoint: where to send a request, and how. */
|
|
226
|
+
export interface RouteAction {
|
|
227
|
+
url: string;
|
|
228
|
+
method: string;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Resolve a named route to both its URL and its HTTP method.
|
|
233
|
+
*
|
|
234
|
+
* The pair is the point. A form that hardcodes a URL can still send the wrong
|
|
235
|
+
* verb, and the failure — a 404 or a 405 on submit — looks nothing like its
|
|
236
|
+
* cause. Taking both from one generated record means a route that changes verb
|
|
237
|
+
* changes it everywhere at once.
|
|
238
|
+
*
|
|
239
|
+
* Throws when the name has no registered verb. An earlier version defaulted to
|
|
240
|
+
* `GET`, and that default cost a real bug: a regenerated table came back empty,
|
|
241
|
+
* every `action()` reported `GET`, and a file upload submitted as a GET to its
|
|
242
|
+
* own store route and 404'd. The point of resolving a verb from a table is that
|
|
243
|
+
* a wrong verb becomes impossible — a silent fallback gives that away for a
|
|
244
|
+
* failure mode nobody reads, so this is loud instead.
|
|
245
|
+
*
|
|
246
|
+
* Use {@link route} for links, which need no verb.
|
|
247
|
+
*
|
|
248
|
+
* @example
|
|
249
|
+
* const submit = action("projects.issues.comments.store", { project: "apollo", issue: 4 });
|
|
250
|
+
* // → { url: "/projects/apollo/issues/4/comments", method: "POST" }
|
|
251
|
+
*/
|
|
252
|
+
export function action<N extends RouteTarget>(name: N, ...args: RouteArgs<N>): RouteAction {
|
|
253
|
+
const method = methodTable.get(name as string);
|
|
254
|
+
if (method === undefined) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`action("${String(name)}"): no HTTP method registered for this route. ` +
|
|
257
|
+
`On the server this is installed at boot, so an empty table means the route ` +
|
|
258
|
+
`is not registered. In a browser bundle, call defineRouteMethods(METHODS) ` +
|
|
259
|
+
`from types/routes.generated.ts at your entry point. ` +
|
|
260
|
+
`Use route() instead for links, which need no verb.`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
return { url: route(name, ...args), method };
|
|
264
|
+
}
|
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,107 @@
|
|
|
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
|
+
// A boolean is never a secret. It has two possible values, so masking one
|
|
93
|
+
// conceals nothing a reader could not guess — while destroying the answer
|
|
94
|
+
// they came for. Names are matched by substring, so this is not
|
|
95
|
+
// hypothetical: `cors.credentials` contains "credential" and came back as
|
|
96
|
+
// `‹redacted›` on the DevTools Config tab, hiding whether credentialed
|
|
97
|
+
// CORS was on. That is a security setting a reader is checking *because*
|
|
98
|
+
// it matters.
|
|
99
|
+
const maskable = typeof item !== "boolean";
|
|
100
|
+
out[key] =
|
|
101
|
+
maskable && options.sensitive(key) ? options.mask : _walk(item, options, depth + 1, seen);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
} finally {
|
|
105
|
+
seen.delete(object);
|
|
106
|
+
}
|
|
107
|
+
}
|