@zerotal/devtools 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.
Files changed (43) hide show
  1. package/CHANGELOG.md +233 -1
  2. package/api-surface.md +296 -0
  3. package/package.json +5 -4
  4. package/src/DevtoolsInjectionMiddleware.ts +41 -4
  5. package/src/RequestTrace.ts +96 -1
  6. package/src/TraceStore.ts +12 -0
  7. package/src/activity.ts +116 -0
  8. package/src/callsite.ts +146 -0
  9. package/src/client/filter.ts +108 -0
  10. package/src/client/index.ts +122 -0
  11. package/src/client/metrics.ts +98 -0
  12. package/src/client/registry.ts +65 -0
  13. package/src/client/state.ts +311 -0
  14. package/src/client/tabs/all.ts +276 -0
  15. package/src/client/tabs/app.ts +292 -0
  16. package/src/client/tabs/cache.ts +49 -0
  17. package/src/client/tabs/channel.ts +263 -0
  18. package/src/client/tabs/exceptions.ts +68 -0
  19. package/src/client/tabs/jobs.ts +50 -0
  20. package/src/client/tabs/logs.ts +44 -0
  21. package/src/client/tabs/mail.ts +59 -0
  22. package/src/client/tabs/queries.ts +124 -0
  23. package/src/client/tabs/request.ts +76 -0
  24. package/src/client/tabs/timeline.ts +132 -0
  25. package/src/client/tabs/types.ts +51 -0
  26. package/src/client/transport.ts +81 -0
  27. package/src/client/tree.ts +138 -0
  28. package/src/client/ui/format.ts +118 -0
  29. package/src/client/ui/render.ts +87 -0
  30. package/src/client/ui/shell.ts +511 -0
  31. package/src/client/ui/theme.ts +389 -0
  32. package/src/client-auto.ts +1 -1
  33. package/src/config.ts +77 -2
  34. package/src/dashboard-auto.ts +1 -1
  35. package/src/editor.ts +107 -0
  36. package/src/enabled.ts +59 -0
  37. package/src/index.ts +19 -3
  38. package/src/map.ts +213 -0
  39. package/src/provider/DevtoolsProvider.ts +32 -7
  40. package/src/redaction.ts +161 -20
  41. package/src/tracing.ts +213 -24
  42. package/src/client.ts +0 -1048
  43. package/src/panel-app.js +0 -519
package/src/enabled.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * One place that answers "is the inspector on, and for whom".
3
+ *
4
+ * Separated from the provider and the middleware because both need the same
5
+ * answer, and two gates that can disagree is how a dev-only surface ends up
6
+ * serving request headers in production.
7
+ *
8
+ * The panel was all-or-nothing on {@link devSurfacesEnabled} until it grew things
9
+ * worth gating: request bodies, session keys, stack traces, the resolved config.
10
+ * That default is still right — a deployed process exposes nothing — but "off
11
+ * everywhere but my laptop" is not the only shape a team needs, and without a
12
+ * supported escape hatch the way you run this on a shared staging box is to lie
13
+ * about `APP_ENV`.
14
+ */
15
+ import { config, devSurfacesEnabled } from "@zerotal/core";
16
+ import { DevtoolsConfig, type DevtoolsConfigShape } from "./config.ts";
17
+
18
+ /** The `devtools` config block, with defaults when config is not loaded. */
19
+ export function devtoolsSettings(): DevtoolsConfigShape {
20
+ return DevtoolsConfig(config.safe<Partial<DevtoolsConfigShape>>("devtools", {}));
21
+ }
22
+
23
+ /**
24
+ * Whether the inspector should run at all.
25
+ *
26
+ * `enabled: null` (the default) defers to {@link devSurfacesEnabled} — the same
27
+ * gate as the stack-trace error page — so the panel is on under `zt dev` and off
28
+ * in a production deploy without anyone configuring it. An explicit `true` or
29
+ * `false` wins, which is what makes it testable and what lets an app run it on a
30
+ * staging box behind a gate.
31
+ */
32
+ export function devtoolsEnabled(): boolean {
33
+ return devtoolsSettings().enabled ?? devSurfacesEnabled();
34
+ }
35
+
36
+ /**
37
+ * Whether this request may reach the inspector's endpoints.
38
+ *
39
+ * A dev process always may — a gate that can lock a developer out of their own
40
+ * machine gets switched off, and then nothing is gated. Anywhere else the app's
41
+ * `gate` decides, and the absence of one is a **refusal** rather than a default
42
+ * allow: an app that turned the inspector on outside development without saying
43
+ * who may read it has not made a decision this code should make for it.
44
+ *
45
+ * One function answers for every endpoint. The SSE stream, the trace JSON, the
46
+ * dashboard, and the panel bundle are the same secret.
47
+ */
48
+ export async function devtoolsAuthorized(request: Request): Promise<boolean> {
49
+ if (devSurfacesEnabled()) return true;
50
+ const gate = devtoolsSettings().gate;
51
+ if (!gate) return false;
52
+ try {
53
+ return await gate(request);
54
+ } catch {
55
+ // A gate that throws has not said yes. Failing open here would turn a typo in
56
+ // someone's authorization check into an open trace inspector.
57
+ return false;
58
+ }
59
+ }
package/src/index.ts CHANGED
@@ -1,14 +1,29 @@
1
1
  // @zerotal/devtools — public API barrel
2
2
 
3
3
  export { DevtoolsProvider } from "./provider/DevtoolsProvider.ts";
4
- export type { DevtoolsPanelPlugin } from "./client.ts";
4
+ export type { DevtoolsPanelPlugin } from "./client/registry.ts";
5
5
  export { DevtoolsInjectionMiddleware, startDevtoolsStream } from "./DevtoolsInjectionMiddleware.ts";
6
6
  export type { DevtoolsInjectionOptions } from "./DevtoolsInjectionMiddleware.ts";
7
7
  export { TraceStore, traceStore, _setTraceStore } from "./TraceStore.ts";
8
8
  export type { TraceStoreOptions } from "./TraceStore.ts";
9
9
  export { DevtoolsConfig } from "./config.ts";
10
- export type { DevtoolsConfigShape } from "./config.ts";
11
- export { redactBindings, attributeBindings } from "./redaction.ts";
10
+ export type { DevtoolsConfigShape, DevtoolsGate } from "./config.ts";
11
+ // Whether the inspector is running, for an app that wants to branch on it. The
12
+ // gate check and the settings reader beside it are plumbing for the middleware
13
+ // and the provider — an app never calls them, and exporting them only so a
14
+ // same-package test can import them is how internals become unchangeable.
15
+ export { devtoolsEnabled } from "./enabled.ts";
16
+ // Types only: both appear on shapes an app can hold (`SourceLocation` on a
17
+ // `QuerySpan`, `EditorName` in its config). The URL builders and the stack walker
18
+ // behind them are the panel's own business.
19
+ export type { EditorName, SourceLocation } from "./editor.ts";
20
+ export {
21
+ redactBindings,
22
+ redactValue,
23
+ redactCacheKey,
24
+ isSensitiveName,
25
+ attributeBindings,
26
+ } from "./redaction.ts";
12
27
  export type { RedactionOptions } from "./redaction.ts";
13
28
  export { traceSink, traceChannels } from "./tracing.ts";
14
29
  export type { TraceSink } from "./tracing.ts";
@@ -20,6 +35,7 @@ export type {
20
35
  CacheEntry,
21
36
  JobEntry,
22
37
  LogEntry,
38
+ ExceptionInfo,
23
39
  RouteInfo,
24
40
  AuthInfo,
25
41
  TraceChannelDescriptor,
package/src/map.ts ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * The application as it is, rather than as it just behaved.
3
+ *
4
+ * Every tab up to here reads the trace stream: what one request did. This reads
5
+ * the framework's own registries — the routes it will match, the config it
6
+ * resolved, what is in the container, which providers put it there, and what
7
+ * listens to what. All of it existed already and all of it was CLI-only or
8
+ * invisible, so the questions it answers ("is that route even registered", "who
9
+ * bound `cache`", "does anything listen to `OrderPlaced`") were answered by
10
+ * reading source.
11
+ *
12
+ * Nothing here is instrumented. It is a read of state the app is already
13
+ * keeping, taken when the panel asks — which is also why it needs no store and
14
+ * no retention: there is only ever one current answer.
15
+ */
16
+ import { Router, FrameworkEvents } from "@zerotal/core";
17
+ import type { Application, Emitter } from "@zerotal/core";
18
+ import { isSensitiveName } from "./redaction.ts";
19
+ import { redactGraph } from "@zerotal/core/security";
20
+ import type { RedactionOptions } from "./redaction.ts";
21
+
22
+ /** One registered route, flattened for display. */
23
+ export interface RouteRow {
24
+ method: string;
25
+ path: string;
26
+ name: string;
27
+ handler: string;
28
+ middleware: string;
29
+ }
30
+
31
+ /** One container binding. */
32
+ export interface BindingRow {
33
+ token: string;
34
+ kind: string;
35
+ /** The provider that bound it, when boot recorded one. */
36
+ provider: string;
37
+ }
38
+
39
+ /** One provider, in boot order. */
40
+ export interface ProviderRow {
41
+ name: string;
42
+ durationMs: number;
43
+ bindings: number;
44
+ }
45
+
46
+ /** One event and what reacts to it. */
47
+ export interface EventRow {
48
+ event: string;
49
+ /** Application listener class names, or the handler count for a framework event. */
50
+ listeners: string;
51
+ source: "application" | "framework";
52
+ }
53
+
54
+ /** Everything the App section draws. */
55
+ export interface FrameworkMap {
56
+ routes: RouteRow[];
57
+ config: Record<string, unknown>;
58
+ bindings: BindingRow[];
59
+ providers: ProviderRow[];
60
+ events: EventRow[];
61
+ /** Wall-clock boot time, so the provider list has a total to be read against. */
62
+ bootMs: number | null;
63
+ }
64
+
65
+ /** `[class PostController]` → `PostController`; a plain token passes through. */
66
+ function tokenName(token: unknown): string {
67
+ if (typeof token === "string") return token;
68
+ if (typeof token === "function") return token.name || "‹anonymous›";
69
+ return String(token);
70
+ }
71
+
72
+ /**
73
+ * Every registered route, newest framework state, sorted for reading.
74
+ *
75
+ * Sorted by path then method rather than by registration order: registration
76
+ * order is an implementation detail of which file loaded first, and a list you
77
+ * scan for "is `/posts/:id` there" wants the paths together.
78
+ */
79
+ export function routeRows(): RouteRow[] {
80
+ // `namedRoutes` is name → path; the panel wants the reverse.
81
+ const nameByPath = new Map<string, string>();
82
+ for (const [name, path] of Router.namedRoutes) nameByPath.set(path, name);
83
+
84
+ return [...Router.routes.values()]
85
+ .map((route) => ({
86
+ method: route.method,
87
+ path: route.path,
88
+ name: nameByPath.get(route.path) ?? "",
89
+ handler: `${route.controller?.name ?? "—"}@${route.action}`,
90
+ middleware: route.middleware
91
+ .map((m) => m.name)
92
+ .filter(Boolean)
93
+ .join(", "),
94
+ }))
95
+ .sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
96
+ }
97
+
98
+ /**
99
+ * The resolved config, with anything that looks like a secret masked.
100
+ *
101
+ * Exposing config is how a debugging tool leaks a database password, so this is
102
+ * the one surface here that is *not* a plain read. The same `isSensitiveName`
103
+ * rule the rest of the package uses decides, which means an app's `allow` and
104
+ * `deny` mean the same thing here as they do on the Queries tab — and it is a
105
+ * deny-by-default rule, so a key nobody anticipated is masked rather than shown.
106
+ */
107
+ export function configTree(
108
+ all: Record<string, unknown>,
109
+ redaction: RedactionOptions,
110
+ ): Record<string, unknown> {
111
+ // Stricter here than anywhere else in the package, deliberately. The shared
112
+ // list masks `api_key` and `private_key` but not a bare `key` — reasonable for
113
+ // a query binding, where a column called `key` is usually a lookup key, and
114
+ // wrong for config, where `app.key` is the application's encryption key. Same
115
+ // reasoning for `dsn`: a connection string is credentials with a hostname
116
+ // attached. Config is the one place secrets are *supposed* to live, so it gets
117
+ // the benefit of the doubt in the other direction.
118
+ const strict: RedactionOptions = {
119
+ ...redaction,
120
+ deny: [...(redaction.deny ?? []), "key", "dsn"],
121
+ };
122
+ return redactGraph(all, {
123
+ sensitive: (key) => isSensitiveName(key, strict),
124
+ mask: "‹redacted›",
125
+ circular: "‹circular›",
126
+ tooDeep: "‹truncated›",
127
+ // Deeper than a trace entry: config is nested by design and a namespace
128
+ // truncated three levels in is a namespace you cannot read.
129
+ maxDepth: 10,
130
+ flatten: (value) => (typeof value === "function" ? "‹fn›" : undefined),
131
+ }) as Record<string, unknown>;
132
+ }
133
+
134
+ /**
135
+ * What is in the container, and who put it there.
136
+ *
137
+ * Provenance comes from the boot report rather than from the container, which
138
+ * does not track it — see `Application.providerReport`.
139
+ */
140
+ export function bindingRows(app: Application): BindingRow[] {
141
+ const owner = new Map<string, string>();
142
+ for (const provider of app.providerReport) {
143
+ for (const token of provider.bindings) owner.set(token, provider.name);
144
+ }
145
+
146
+ return [...app.container.registry.entries()]
147
+ .map(([token, binding]) => {
148
+ const name = tokenName(token);
149
+ return {
150
+ token: name,
151
+ kind: (binding as { kind?: string }).kind ?? "unknown",
152
+ provider: owner.get(name) ?? "—",
153
+ };
154
+ })
155
+ .sort((a, b) => a.token.localeCompare(b.token));
156
+ }
157
+
158
+ /** Providers in boot order, with what each cost. */
159
+ export function providerRows(app: Application): ProviderRow[] {
160
+ return app.providerReport.map((p) => ({
161
+ name: p.name,
162
+ durationMs: p.durationMs,
163
+ bindings: p.bindings.length,
164
+ }));
165
+ }
166
+
167
+ /**
168
+ * Application listeners and framework subscribers, in one list.
169
+ *
170
+ * Two different mechanisms — `Emitter.on()` for the app's own events,
171
+ * `FrameworkEvents.on()` for the framework bus — and a developer asking "what
172
+ * reacts to this" does not care which. The `source` column keeps them
173
+ * distinguishable without splitting the answer in two.
174
+ */
175
+ export function eventRows(emitter: Emitter | undefined): EventRow[] {
176
+ const rows: EventRow[] = [];
177
+
178
+ for (const { event, listeners } of emitter?.registrations() ?? []) {
179
+ rows.push({ event, listeners: listeners.join(", "), source: "application" });
180
+ }
181
+ for (const { event, handlers } of FrameworkEvents.subscriptions()) {
182
+ rows.push({
183
+ event,
184
+ listeners: `${handlers} subscriber${handlers === 1 ? "" : "s"}`,
185
+ source: "framework",
186
+ });
187
+ }
188
+ return rows.sort((a, b) => a.event.localeCompare(b.event));
189
+ }
190
+
191
+ /**
192
+ * Read the whole map.
193
+ *
194
+ * Taken fresh on each request for it. The registries are small and static, and a
195
+ * cached map is a map that disagrees with the app the moment a provider
196
+ * registers a route late.
197
+ */
198
+ export function buildFrameworkMap(app: Application, redaction: RedactionOptions): FrameworkMap {
199
+ const config = app.container.tryMake("config");
200
+ const emitter = app.container.tryMake("events") as Emitter | undefined;
201
+
202
+ return {
203
+ routes: routeRows(),
204
+ config: configTree(
205
+ (config as { all?: () => Record<string, unknown> } | undefined)?.all?.() ?? {},
206
+ redaction,
207
+ ),
208
+ bindings: bindingRows(app),
209
+ providers: providerRows(app),
210
+ events: eventRows(emitter),
211
+ bootMs: app.bootDurationMs ?? null,
212
+ };
213
+ }
@@ -4,6 +4,8 @@ import {
4
4
  type AppEnvironment,
5
5
  type HttpContext,
6
6
  } from "@zerotal/core";
7
+ import { devtoolsEnabled } from "../enabled.ts";
8
+ import { startActivityCapture, _resetActivity } from "../activity.ts";
7
9
  import { DevReloadMiddleware, registerDevHtmlSnippet } from "@zerotal/core/dev";
8
10
  import {
9
11
  DevtoolsInjectionMiddleware,
@@ -19,6 +21,8 @@ import {
19
21
  traceSink,
20
22
  _resetChannels,
21
23
  _setRedaction,
24
+ _setCaptureSource,
25
+ _setHeaderAllowlist,
22
26
  type TraceSink,
23
27
  } from "../tracing.ts";
24
28
 
@@ -69,6 +73,7 @@ export class DevtoolsProvider extends ServiceProvider {
69
73
  /** Set once the provider has activated, so teardown only undoes what it did. */
70
74
  private _active = false;
71
75
  private _stopStream: (() => void) | null = null;
76
+ private _stopActivity: (() => void) | null = null;
72
77
 
73
78
  override async onBooting(): Promise<void> {
74
79
  // Fail closed: only activate for explicitly non-prod environments. An unset or
@@ -80,7 +85,12 @@ export class DevtoolsProvider extends ServiceProvider {
80
85
  // runtime mode, so this was asking whether `"web"` is a development environment;
81
86
  // - it is the only dev gate that did not honour `ZT_DEV`, which is what the dev
82
87
  // orchestrator sets on the server it supervises — so `zt dev` did not help either.
83
- if (!devSurfacesEnabled()) return;
88
+ // `devtoolsEnabled()` rather than `devSurfacesEnabled()` directly: the app's
89
+ // `enabled` setting wins when it is set, which is what lets the inspector run
90
+ // on a shared staging box behind a `gate`. `null` — the default — still
91
+ // defers to the dev-surface gate, so nothing changes for anyone who has not
92
+ // asked for it.
93
+ if (!devtoolsEnabled()) return;
84
94
  this._active = true;
85
95
 
86
96
  const config = this._config();
@@ -96,6 +106,8 @@ export class DevtoolsProvider extends ServiceProvider {
96
106
  }),
97
107
  );
98
108
  _setRedaction(config.redact);
109
+ _setCaptureSource(config.captureSource);
110
+ _setHeaderAllowlist(config.headers);
99
111
 
100
112
  // Expose the trace sink so feature packages can contribute per-request spans
101
113
  // and declare their own channels. Bound in onBooting so it is available when
@@ -113,12 +125,19 @@ export class DevtoolsProvider extends ServiceProvider {
113
125
  // injector — no `DevTools.start()` needed in the app's own bundle. Also
114
126
  // register the injector so this works under a plain `serve` (not only
115
127
  // `serve --dev-worker`, where Application.enableDevWs() already adds it).
116
- this.app.useOnce(DevReloadMiddleware);
117
- registerDevHtmlSnippet("zerotal-devtools", (ctx: HttpContext) =>
118
- ctx.url.pathname.startsWith("/__zerotal")
119
- ? "" // don't inject the panel into the devtools' own pages
120
- : `<script type="module" src="/__zerotal/devtools/client.js"></script>`,
121
- );
128
+ //
129
+ // Only on a development machine. Auto-injection is a convenience for the
130
+ // person running the app; on a gated environment the snippet would go into
131
+ // every visitor's HTML and then 403 in their console, so there the way in is
132
+ // the dashboard at `/__zerotal/devtools`, which the gate answers for.
133
+ if (devSurfacesEnabled()) {
134
+ this.app.useOnce(DevReloadMiddleware);
135
+ registerDevHtmlSnippet("zerotal-devtools", (ctx: HttpContext) =>
136
+ ctx.url.pathname.startsWith("/__zerotal")
137
+ ? "" // don't inject the panel into the devtools' own pages
138
+ : `<script type="module" src="/__zerotal/devtools/client.js"></script>`,
139
+ );
140
+ }
122
141
  }
123
142
 
124
143
  override async onBooted(): Promise<void> {
@@ -128,6 +147,9 @@ export class DevtoolsProvider extends ServiceProvider {
128
147
  // devtools no longer imports @zerotal/orm — it only consumes FrameworkEvents.
129
148
  startDevtoolsTracing();
130
149
  startConsoleCapture();
150
+ // Console commands and scheduled tasks, which have no request to hang off
151
+ // and so appeared nowhere at all.
152
+ this._stopActivity = startActivityCapture();
131
153
  this._stopStream = startDevtoolsStream();
132
154
 
133
155
  process.stdout.write(
@@ -141,9 +163,12 @@ export class DevtoolsProvider extends ServiceProvider {
141
163
  if (!this._active) return;
142
164
  stopDevtoolsTracing();
143
165
  stopConsoleCapture();
166
+ this._stopActivity?.();
167
+ this._stopActivity = null;
144
168
  this._stopStream?.();
145
169
  this._stopStream = null;
146
170
  _resetChannels();
171
+ _resetActivity();
147
172
  // Flushes any pending batch and closes the database — without this a suite
148
173
  // that boots several apps leaves a handle and an hourly timer per app.
149
174
  _setTraceStore(null);
package/src/redaction.ts CHANGED
@@ -1,19 +1,30 @@
1
1
  /**
2
- * Query-binding redaction.
2
+ * Trace redaction.
3
3
  *
4
4
  * A trace does not stay on screen: it is streamed to the browser and written to
5
- * `.zerotal/devtools.sqlite`, where it sits for a day. Bindings are the request's
6
- * actual values — the password on a registration, a reset token, a session
7
- * payload, every customer email a listing selects by. An ephemeral dev panel and
8
- * a plaintext file with a day's worth of credentials in it are different risks,
9
- * so the values are masked by default and you opt individual columns back in.
5
+ * `.zerotal/devtools.sqlite`, where it sits for a day. What it carries is the
6
+ * request's actual values — the password on a registration, a reset token, a
7
+ * session payload, every customer email a listing selects by. An ephemeral dev
8
+ * panel and a plaintext file with a day's worth of credentials in it are
9
+ * different risks, so those values are masked by default and you opt individual
10
+ * names back in.
10
11
  *
11
- * Matching is on the column each binding belongs to, recovered by pairing the
12
- * SQL's placeholders with the identifiers around them. When a binding cannot be
13
- * attributed to a column a raw expression, a dialect this does not parse it
14
- * is masked, because guessing wrong in the other direction is what writes a
15
- * password to disk.
12
+ * Three entry points, one rule. {@link redactBindings} masks query bindings by
13
+ * the column each belongs to, recovered by pairing the SQL's placeholders with
14
+ * the identifiers around them; when a binding cannot be attributed to a column
15
+ * a raw expression, a dialect this does not parse it is masked, because
16
+ * guessing wrong in the other direction is what writes a password to disk.
17
+ * {@link redactValue} walks anything with named fields (a channel entry, a
18
+ * logged object) and masks by field name. {@link redactCacheKey} masks the tail
19
+ * of a cache key whose name says it holds a secret.
20
+ *
21
+ * All three share {@link isSensitiveName}, so `allow` and `deny` in the app's
22
+ * config mean the same thing everywhere. The walk itself is `redactGraph` from
23
+ * `@zerotal/core/security` — the same one the Inertia recorder runs, which is
24
+ * how two recorders that must not agree on their *markers* still agree on how a
25
+ * cycle, a depth limit, and a nested secret are handled.
16
26
  */
27
+ import { redactGraph } from "@zerotal/core/security";
17
28
 
18
29
  export interface RedactionOptions {
19
30
  /**
@@ -22,12 +33,13 @@ export interface RedactionOptions {
22
33
  */
23
34
  enabled?: boolean;
24
35
  /**
25
- * Column names whose values are safe to show in full. Matched
26
- * case-insensitively against the column each binding belongs to.
36
+ * Names whose values are safe to show in full. Matched case-insensitively
37
+ * against the column a binding belongs to, a channel entry's field name, or a
38
+ * cache key's segment.
27
39
  */
28
40
  allow?: string[];
29
41
  /**
30
- * Extra column names to mask, added to the built-in list.
42
+ * Extra names to mask, added to the built-in list.
31
43
  */
32
44
  deny?: string[];
33
45
  }
@@ -64,9 +76,141 @@ const SENSITIVE = [
64
76
  /** Columns always shown: structural values that make a trace readable at all. */
65
77
  const STRUCTURAL = ["id", "created_at", "updated_at", "deleted_at"];
66
78
 
67
- /** What a masked binding is replaced with. */
79
+ /** What a masked value is replaced with. */
68
80
  const MASK = "‹redacted›";
69
81
 
82
+ /** Stand-ins for values a walk cannot render rather than will not. */
83
+ const CIRCULAR = "‹circular›";
84
+ const TOO_DEEP = "‹truncated›";
85
+
86
+ /**
87
+ * How far {@link redactValue} descends before it stops.
88
+ *
89
+ * A panel row shows a few fields, not an object graph, and the walk runs on
90
+ * every entry of every request — so the cap is about the cost of the walk as
91
+ * much as the size of what comes out of it.
92
+ */
93
+ const MAX_DEPTH = 6;
94
+
95
+ /** The allow/deny sets a redaction pass runs against. */
96
+ interface Rules {
97
+ allow: Set<string>;
98
+ deny: string[];
99
+ }
100
+
101
+ function _rules(options: RedactionOptions): Rules {
102
+ return {
103
+ allow: new Set((options.allow ?? []).map((c) => c.toLowerCase())),
104
+ deny: [...SENSITIVE, ...(options.deny ?? []).map((c) => c.toLowerCase())],
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Whether a name means "the value under me is a secret".
110
+ *
111
+ * The one place the rule lives, so a column, a channel field, and a cache-key
112
+ * segment are judged the same way — and so `allow`/`deny` in the app's config
113
+ * cannot mean one thing on the Queries tab and another on the Cache tab.
114
+ *
115
+ * Matching is by substring, which is why `password` covers `password_hash`. It
116
+ * also means `author_id` matches `auth`; that is the trade the built-in list
117
+ * makes, and `allow: ['author_id']` opts it back in.
118
+ */
119
+ export function isSensitiveName(name: string, options: RedactionOptions = {}): boolean {
120
+ return _sensitive(name, _rules(options));
121
+ }
122
+
123
+ function _sensitive(name: string, { allow, deny }: Rules): boolean {
124
+ const key = name.toLowerCase();
125
+ if (allow.has(key)) return false;
126
+ if (STRUCTURAL.includes(key)) return false;
127
+ return deny.some((s) => key.includes(s));
128
+ }
129
+
130
+ /**
131
+ * Mask every field of `value` whose name says it holds a secret.
132
+ *
133
+ * Used at the sink boundary for the things that arrive as named fields rather
134
+ * than as SQL — a channel entry a package recorded, an object someone passed to
135
+ * `console.log`. A bare scalar has no name to judge it by and comes back
136
+ * unchanged; masking on the *contents* of a string is a different, guessier job
137
+ * this deliberately does not attempt.
138
+ *
139
+ * Cycles and over-deep branches are replaced rather than followed, so the result
140
+ * is always safe to `JSON.stringify` — which is the other half of why the log
141
+ * capture calls this: a circular argument used to throw out of the console patch.
142
+ *
143
+ * @returns A new value; the input is not modified.
144
+ */
145
+ export function redactValue(value: unknown, options: RedactionOptions = {}): unknown {
146
+ if (options.enabled === false) return value;
147
+ const rules = _rules(options);
148
+ return redactGraph(value, {
149
+ sensitive: (key) => _sensitive(key, rules),
150
+ mask: MASK,
151
+ circular: CIRCULAR,
152
+ tooDeep: TOO_DEEP,
153
+ maxDepth: MAX_DEPTH,
154
+ flatten: _flatten,
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Three shapes that read better named than walked, and that a devtools entry
160
+ * carries often enough to be worth naming. A function is here because
161
+ * `JSON.stringify` drops the key it sits under, and a log line that quietly
162
+ * loses a field is the kind of wrongness a debugging tool must not have.
163
+ */
164
+ function _flatten(value: unknown): string | undefined {
165
+ if (value instanceof Date) return value.toISOString();
166
+ if (value instanceof Error) return `${value.name}: ${value.message}`;
167
+ if (typeof value === "function") return `‹fn ${value.name || "anonymous"}›`;
168
+ return undefined;
169
+ }
170
+
171
+ /**
172
+ * What separates a cache key's *name* from its *value*, kept in the split so the
173
+ * key rebuilds.
174
+ *
175
+ * Deliberately not `_` or `-`: those sit inside a name — `password_reset` is one
176
+ * word — so splitting on them would mask `reset` and leave the row unreadable
177
+ * without protecting anything.
178
+ */
179
+ const KEY_PARTS = /([:|/.])/;
180
+
181
+ /**
182
+ * Mask the identifying tail of a cache key whose name says it holds a secret.
183
+ *
184
+ * A cache key is a name and a value welded together: `password_reset:9f2c…` *is*
185
+ * the reset token. Masking the whole key would leave the Cache tab a column of
186
+ * `‹redacted›` with nothing to read it by, so only what follows the sensitive
187
+ * segment goes — the name that makes the row identifiable stays.
188
+ *
189
+ * @returns The key with its tail masked, or the key unchanged.
190
+ */
191
+ export function redactCacheKey(key: string, options: RedactionOptions = {}): string {
192
+ if (options.enabled === false) return key;
193
+ if (!key) return key;
194
+
195
+ const rules = _rules(options);
196
+ const parts = key.split(KEY_PARTS);
197
+ let masked = false;
198
+
199
+ const out = parts.map((part, i) => {
200
+ // Odd indices are the captured separators; they hold no value.
201
+ if (i % 2 === 1) return part;
202
+ if (masked) return part ? MASK : part;
203
+ if (_sensitive(part, rules)) masked = true;
204
+ return part;
205
+ });
206
+
207
+ // A key with no separator to split a name from its value — `sessionabc123` —
208
+ // cannot be trimmed to its name, so it goes whole. Naming a value we cannot
209
+ // clear is the wrong half to keep.
210
+ if (!masked) return key;
211
+ return out.length === 1 ? MASK : out.join("");
212
+ }
213
+
70
214
  /**
71
215
  * Mask the bindings of `sql` that belong to a sensitive column.
72
216
  *
@@ -83,8 +227,7 @@ export function redactBindings(
83
227
  if (options.enabled === false) return bindings;
84
228
  if (!Array.isArray(bindings) || bindings.length === 0) return bindings;
85
229
 
86
- const allow = new Set((options.allow ?? []).map((c) => c.toLowerCase()));
87
- const deny = [...SENSITIVE, ...(options.deny ?? []).map((c) => c.toLowerCase())];
230
+ const rules = _rules(options);
88
231
  const columns = attributeBindings(sql, bindings.length);
89
232
 
90
233
  return bindings.map((value, i) => {
@@ -93,9 +236,7 @@ export function redactBindings(
93
236
  // An unattributable binding is masked: a value we cannot name is a value we
94
237
  // cannot clear.
95
238
  if (!column) return MASK;
96
- if (allow.has(column)) return value;
97
- if (STRUCTURAL.includes(column)) return value;
98
- return deny.some((s) => column.includes(s)) ? MASK : value;
239
+ return _sensitive(column, rules) ? MASK : value;
99
240
  });
100
241
  }
101
242