@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.
- package/CHANGELOG.md +233 -1
- package/api-surface.md +296 -0
- package/package.json +5 -4
- package/src/DevtoolsInjectionMiddleware.ts +41 -4
- package/src/RequestTrace.ts +96 -1
- package/src/TraceStore.ts +12 -0
- package/src/activity.ts +116 -0
- package/src/callsite.ts +146 -0
- package/src/client/filter.ts +108 -0
- package/src/client/index.ts +122 -0
- package/src/client/metrics.ts +98 -0
- package/src/client/registry.ts +65 -0
- package/src/client/state.ts +311 -0
- package/src/client/tabs/all.ts +276 -0
- package/src/client/tabs/app.ts +292 -0
- package/src/client/tabs/cache.ts +49 -0
- package/src/client/tabs/channel.ts +263 -0
- package/src/client/tabs/exceptions.ts +68 -0
- package/src/client/tabs/jobs.ts +50 -0
- package/src/client/tabs/logs.ts +44 -0
- package/src/client/tabs/mail.ts +59 -0
- package/src/client/tabs/queries.ts +124 -0
- package/src/client/tabs/request.ts +76 -0
- package/src/client/tabs/timeline.ts +132 -0
- package/src/client/tabs/types.ts +51 -0
- package/src/client/transport.ts +81 -0
- package/src/client/tree.ts +138 -0
- package/src/client/ui/format.ts +118 -0
- package/src/client/ui/render.ts +87 -0
- package/src/client/ui/shell.ts +511 -0
- package/src/client/ui/theme.ts +389 -0
- package/src/client-auto.ts +1 -1
- package/src/config.ts +77 -2
- package/src/dashboard-auto.ts +1 -1
- package/src/editor.ts +107 -0
- package/src/enabled.ts +59 -0
- package/src/index.ts +19 -3
- package/src/map.ts +213 -0
- package/src/provider/DevtoolsProvider.ts +32 -7
- package/src/redaction.ts +161 -20
- package/src/tracing.ts +213 -24
- package/src/client.ts +0 -1048
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
*
|
|
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.
|
|
6
|
-
* actual values — the password on a registration, a reset token, a
|
|
7
|
-
* payload, every customer email a listing selects by. An ephemeral dev
|
|
8
|
-
* a plaintext file with a day's worth of credentials in it are
|
|
9
|
-
* so
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
26
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|