@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.
@@ -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();
@@ -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,
@@ -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
- headers ? { headers } : undefined,
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;
@@ -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 and the URL pattern it compiles to. `route()` reads this",
36
- "// through declaration merging, so an unknown name or a missing :param is a",
37
- "// compile error. Commit this file: editors and CI need it without booting the app.",
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
- ([name, pattern]) =>
63
- ` ${/^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name)}: ` +
64
- `${JSON.stringify(pattern)},`,
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()`) and `check` (compare only, never write).
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
- const content = generateRouteTypes(namedRoutes);
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
+ }
@@ -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
+ }
@@ -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
+ }