@zerotal/core 1.6.3 → 1.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +176 -0
- package/api-surface.md +3609 -0
- package/package.json +3 -1
- package/src/application/Application.ts +174 -11
- package/src/command/builtin/DoctorCommand.ts +53 -6
- package/src/command/builtin/RouteTypesCommand.ts +1 -0
- package/src/dev/DevDeck.ts +144 -20
- package/src/dev/DevOrchestrator.ts +1 -1
- package/src/doctor/HeaderProbe.ts +164 -0
- package/src/events/Emitter.ts +24 -0
- package/src/events/FrameworkEvents.ts +42 -0
- package/src/helpers/index.ts +43 -28
- package/src/index.ts +2 -0
- package/src/middleware/BaseMiddleware.ts +12 -1
- package/src/middleware/SecureHeadersMiddleware.ts +54 -23
- package/src/provider/StorageProvider.ts +4 -1
- package/src/router/RouteHandler.ts +5 -0
- package/src/router/Router.ts +62 -3
- package/src/router/routeTypes.ts +52 -11
- package/src/router/routes.ts +115 -0
- package/src/security/index.ts +6 -0
- package/src/security/redactGraph.ts +107 -0
- package/src/support/deepMerge.ts +42 -2
- package/src/support/env.ts +48 -8
package/src/dev/DevDeck.ts
CHANGED
|
@@ -19,10 +19,19 @@
|
|
|
19
19
|
* ## The terminal must survive us
|
|
20
20
|
*
|
|
21
21
|
* Raw mode plus the alternate screen left on is the classic TUI bug: the shell
|
|
22
|
-
* comes back with no echo, no line editing, and no scrollback. The restore is
|
|
22
|
+
* comes back with no echo, no line editing, and no shell scrollback. The restore is
|
|
23
23
|
* therefore registered *before* raw mode is entered, and it runs on every exit
|
|
24
24
|
* path there is — `q`, a signal, and an uncaught throw. It is also idempotent,
|
|
25
25
|
* because several of those can happen at once.
|
|
26
|
+
*
|
|
27
|
+
* ## Scrolling is ours to do
|
|
28
|
+
*
|
|
29
|
+
* The alternate screen has no scrollback of its own, so the terminal's scrollbar
|
|
30
|
+
* and wheel have nothing to move: from the moment tabs mode starts, every way of
|
|
31
|
+
* looking at an older line has to come from here. Each card keeps its own buffer
|
|
32
|
+
* and its own position in it, the wheel arrives as cursor keys, and a card that
|
|
33
|
+
* has been scrolled up holds its place while the process behind it keeps
|
|
34
|
+
* printing.
|
|
26
35
|
*/
|
|
27
36
|
import type { OutputWriter } from "../command/OutputWriter.ts";
|
|
28
37
|
import type { DevProcessColor } from "./DevProcess.ts";
|
|
@@ -184,7 +193,7 @@ export class TabsDeck implements Deck {
|
|
|
184
193
|
private _restored = false;
|
|
185
194
|
private _stream: StreamDeck | undefined;
|
|
186
195
|
|
|
187
|
-
private readonly _onData = (chunk: Buffer | string): void => this.
|
|
196
|
+
private readonly _onData = (chunk: Buffer | string): void => this._input(chunk.toString());
|
|
188
197
|
private readonly _onResize = (): void => this._paint();
|
|
189
198
|
private readonly _onExit = (): void => this.stop();
|
|
190
199
|
|
|
@@ -207,7 +216,7 @@ export class TabsDeck implements Deck {
|
|
|
207
216
|
process.on("uncaughtException", this._onExit);
|
|
208
217
|
process.on("unhandledRejection", this._onExit);
|
|
209
218
|
|
|
210
|
-
this._write(ALT_SCREEN_ON + CURSOR_HIDE);
|
|
219
|
+
this._write(ALT_SCREEN_ON + CURSOR_HIDE + ALT_SCROLL_ON);
|
|
211
220
|
this._stdin.setRawMode?.(true);
|
|
212
221
|
this._stdin.resume?.();
|
|
213
222
|
this._stdin.on?.("data", this._onData);
|
|
@@ -224,6 +233,7 @@ export class TabsDeck implements Deck {
|
|
|
224
233
|
const body = stream === "stderr" ? _paint(text, "red") : text;
|
|
225
234
|
card.lines.push(`${stamp}${body}`);
|
|
226
235
|
if (card.lines.length > SCROLLBACK) card.lines.shift();
|
|
236
|
+
this._anchor(card, body);
|
|
227
237
|
|
|
228
238
|
// A tab the user is not looking at still buffers; only the visible one costs
|
|
229
239
|
// a repaint. In stream mode every line prints regardless of focus.
|
|
@@ -271,26 +281,38 @@ export class TabsDeck implements Deck {
|
|
|
271
281
|
}
|
|
272
282
|
this._stdin.pause?.();
|
|
273
283
|
if (this._repaintTimer) clearTimeout(this._repaintTimer);
|
|
274
|
-
this._write(CURSOR_SHOW + ALT_SCREEN_OFF);
|
|
284
|
+
this._write(ALT_SCROLL_OFF + CURSOR_SHOW + ALT_SCREEN_OFF);
|
|
275
285
|
}
|
|
276
286
|
|
|
277
287
|
// ── Input ──────────────────────────────────────────────────────────────────
|
|
278
288
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
289
|
+
/**
|
|
290
|
+
* One read from stdin, which is not the same thing as one key.
|
|
291
|
+
*
|
|
292
|
+
* A wheel tick arrives as the same arrow sequence repeated as many times as
|
|
293
|
+
* there are lines to scroll, all in a single read, and two fast keystrokes
|
|
294
|
+
* arrive together — so a chunk is split into keys first and the frame is
|
|
295
|
+
* painted once at the end rather than once per key.
|
|
296
|
+
*/
|
|
297
|
+
private _input(chunk: string): void {
|
|
298
|
+
let dirty = false;
|
|
299
|
+
for (const key of _keys(chunk)) dirty = this._key(key) || dirty;
|
|
300
|
+
if (dirty) this._paint();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Handle one key. Returns whether the frame needs repainting. */
|
|
304
|
+
private _key(sequence: string): boolean {
|
|
305
|
+
if (this._searching) return this._searchKey(sequence);
|
|
284
306
|
|
|
285
307
|
switch (sequence) {
|
|
286
308
|
case "q":
|
|
287
309
|
case "\x03": // Ctrl-C — the deck owns the terminal, so it owns the quit.
|
|
288
310
|
this._options.onQuit();
|
|
289
|
-
return;
|
|
311
|
+
return false;
|
|
290
312
|
case "r": {
|
|
291
313
|
const card = this._cards[this._focused];
|
|
292
314
|
if (card) this._options.onRestart(card.status.name);
|
|
293
|
-
return;
|
|
315
|
+
return false;
|
|
294
316
|
}
|
|
295
317
|
case "c": {
|
|
296
318
|
const card = this._cards[this._focused];
|
|
@@ -306,7 +328,7 @@ export class TabsDeck implements Deck {
|
|
|
306
328
|
break;
|
|
307
329
|
case "s":
|
|
308
330
|
this._toggleStream();
|
|
309
|
-
return;
|
|
331
|
+
return false;
|
|
310
332
|
case "t":
|
|
311
333
|
this._timestamps = !this._timestamps;
|
|
312
334
|
break;
|
|
@@ -317,22 +339,39 @@ export class TabsDeck implements Deck {
|
|
|
317
339
|
case ARROW_LEFT:
|
|
318
340
|
this._focus(this._focused - 1);
|
|
319
341
|
break;
|
|
342
|
+
// The wheel is these two: in the alternate screen a terminal has no
|
|
343
|
+
// scrollback to move, so it sends the cursor keys instead (which is what
|
|
344
|
+
// `ALT_SCROLL_ON` asks for). Ignoring them is what makes the deck look
|
|
345
|
+
// like a terminal that will not scroll.
|
|
346
|
+
case ARROW_UP:
|
|
347
|
+
this._scroll(1);
|
|
348
|
+
break;
|
|
349
|
+
case ARROW_DOWN:
|
|
350
|
+
this._scroll(-1);
|
|
351
|
+
break;
|
|
320
352
|
case PAGE_UP:
|
|
321
353
|
this._scroll(this._bodyHeight());
|
|
322
354
|
break;
|
|
323
355
|
case PAGE_DOWN:
|
|
324
356
|
this._scroll(-this._bodyHeight());
|
|
325
357
|
break;
|
|
358
|
+
case HOME:
|
|
359
|
+
this._scroll(Number.MAX_SAFE_INTEGER);
|
|
360
|
+
break;
|
|
361
|
+
case END:
|
|
362
|
+
this._scroll(-Number.MAX_SAFE_INTEGER);
|
|
363
|
+
break;
|
|
326
364
|
default: {
|
|
327
365
|
if (sequence >= "1" && sequence <= "9") this._focus(Number(sequence) - 1);
|
|
328
|
-
else return;
|
|
366
|
+
else return false;
|
|
329
367
|
}
|
|
330
368
|
}
|
|
331
369
|
|
|
332
|
-
|
|
370
|
+
return true;
|
|
333
371
|
}
|
|
334
372
|
|
|
335
|
-
|
|
373
|
+
/** Handle one key while the search box has the keyboard. Always repaints. */
|
|
374
|
+
private _searchKey(sequence: string): boolean {
|
|
336
375
|
if (sequence === "\r" || sequence === "\n" || sequence === "\x1b") {
|
|
337
376
|
// Enter keeps the filter and hands the keyboard back; Escape drops it.
|
|
338
377
|
this._searching = false;
|
|
@@ -348,7 +387,7 @@ export class TabsDeck implements Deck {
|
|
|
348
387
|
|
|
349
388
|
const card = this._cards[this._focused];
|
|
350
389
|
if (card) card.scroll = 0;
|
|
351
|
-
|
|
390
|
+
return true;
|
|
352
391
|
}
|
|
353
392
|
|
|
354
393
|
private _focus(index: number): void {
|
|
@@ -361,11 +400,36 @@ export class TabsDeck implements Deck {
|
|
|
361
400
|
private _scroll(by: number): void {
|
|
362
401
|
const card = this._cards[this._focused];
|
|
363
402
|
if (!card) return;
|
|
364
|
-
const
|
|
365
|
-
const max = Math.max(0, total - this._bodyHeight());
|
|
403
|
+
const max = Math.max(0, this._visibleLines(card).length - this._bodyHeight());
|
|
366
404
|
card.scroll = Math.min(max, Math.max(0, card.scroll + by));
|
|
367
405
|
}
|
|
368
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Hold a scrolled-up view on the lines it was left on.
|
|
409
|
+
*
|
|
410
|
+
* `scroll` counts lines up from the newest, so a busy process would otherwise
|
|
411
|
+
* drag the window down by one line for every line it printed and the text
|
|
412
|
+
* somebody stopped to read would slide off the top while they read it. A card
|
|
413
|
+
* pinned to the bottom (`scroll === 0`) is left alone — that one *should*
|
|
414
|
+
* follow the output.
|
|
415
|
+
*/
|
|
416
|
+
private _anchor(card: Card, appended: string): void {
|
|
417
|
+
if (card.scroll === 0) return;
|
|
418
|
+
// Only the focused card is ever filtered — `_focus()` clears the search —
|
|
419
|
+
// so only there can an arriving line be invisible and move nothing.
|
|
420
|
+
const needle = this._cards[this._focused] === card ? this._search.toLowerCase() : "";
|
|
421
|
+
const visible = !needle || appended.toLowerCase().includes(needle);
|
|
422
|
+
// Unfiltered, the count is the buffer's own — worth the branch, because this
|
|
423
|
+
// runs once per line of output and the buffer holds thousands.
|
|
424
|
+
const total = needle ? this._visibleLines(card).length : card.lines.length;
|
|
425
|
+
// Clamped even when nothing was added: at the scrollback cap every new line
|
|
426
|
+
// evicts an old one, and without this the view would walk off the top.
|
|
427
|
+
card.scroll = Math.min(
|
|
428
|
+
card.scroll + (visible ? 1 : 0),
|
|
429
|
+
Math.max(0, total - this._bodyHeight()),
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
369
433
|
/**
|
|
370
434
|
* Hand the terminal back and print plainly from here on.
|
|
371
435
|
*
|
|
@@ -384,7 +448,7 @@ export class TabsDeck implements Deck {
|
|
|
384
448
|
return;
|
|
385
449
|
}
|
|
386
450
|
this._stream = undefined as StreamDeck | undefined;
|
|
387
|
-
this._write(ALT_SCREEN_ON + CURSOR_HIDE);
|
|
451
|
+
this._write(ALT_SCREEN_ON + CURSOR_HIDE + ALT_SCROLL_ON);
|
|
388
452
|
this._stdin.setRawMode?.(true);
|
|
389
453
|
this._paint();
|
|
390
454
|
}
|
|
@@ -453,7 +517,7 @@ export class TabsDeck implements Deck {
|
|
|
453
517
|
const scrolled = card && card.scroll > 0 ? ` ↑${card.scroll}` : "";
|
|
454
518
|
const filtered = this._search ? ` /${this._search}` : "";
|
|
455
519
|
const keys =
|
|
456
|
-
"1-9 tab · ←/→ cycle · r restart · c clear · / search · t time · s stream · q quit";
|
|
520
|
+
"1-9 tab · ←/→ cycle · ↑/↓ scroll · r restart · c clear · / search · t time · s stream · q quit";
|
|
457
521
|
return _fit(_paint(keys + filtered + scrolled, "dim"), width);
|
|
458
522
|
}
|
|
459
523
|
|
|
@@ -486,10 +550,70 @@ const CURSOR_HOME = "\x1b[H";
|
|
|
486
550
|
const CLEAR_LINE = "\x1b[K";
|
|
487
551
|
const CLEAR_BELOW = "\x1b[J";
|
|
488
552
|
|
|
553
|
+
/**
|
|
554
|
+
* Alternate scroll: ask the terminal to send cursor keys when the wheel turns.
|
|
555
|
+
*
|
|
556
|
+
* On the alternate screen there is no scrollback for the wheel to move, so
|
|
557
|
+
* without this the wheel does nothing at all and the deck reads as frozen.
|
|
558
|
+
* Most terminals do it by default, some do not, and asking costs one sequence.
|
|
559
|
+
* Deliberately *not* mouse tracking (`?1000h` and friends), which would hand us
|
|
560
|
+
* real wheel events at the price of the terminal's own text selection.
|
|
561
|
+
*/
|
|
562
|
+
const ALT_SCROLL_ON = "\x1b[?1007h";
|
|
563
|
+
const ALT_SCROLL_OFF = "\x1b[?1007l";
|
|
564
|
+
|
|
565
|
+
const ARROW_UP = "\x1b[A";
|
|
566
|
+
const ARROW_DOWN = "\x1b[B";
|
|
489
567
|
const ARROW_LEFT = "\x1b[D";
|
|
490
568
|
const ARROW_RIGHT = "\x1b[C";
|
|
491
569
|
const PAGE_UP = "\x1b[5~";
|
|
492
570
|
const PAGE_DOWN = "\x1b[6~";
|
|
571
|
+
const HOME = "\x1b[H";
|
|
572
|
+
const END = "\x1b[F";
|
|
573
|
+
|
|
574
|
+
/** A CSI sequence ends at the first byte in this range; everything before is parameters. */
|
|
575
|
+
const _CSI_END = /[@-~]/;
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Split one read from stdin into individual key presses.
|
|
579
|
+
*
|
|
580
|
+
* SS3 arrows (`ESC O A`, what a terminal in application cursor mode sends) are
|
|
581
|
+
* rewritten to their CSI spelling so the switch upstairs only has to know one
|
|
582
|
+
* form of each key, and text is walked by code point so that an emoji typed
|
|
583
|
+
* into the search box stays one key rather than two broken halves.
|
|
584
|
+
*/
|
|
585
|
+
function _keys(chunk: string): string[] {
|
|
586
|
+
const out: string[] = [];
|
|
587
|
+
let at = 0;
|
|
588
|
+
|
|
589
|
+
while (at < chunk.length) {
|
|
590
|
+
if (chunk[at] !== "\x1b") {
|
|
591
|
+
const point = String.fromCodePoint(chunk.codePointAt(at)!);
|
|
592
|
+
out.push(point);
|
|
593
|
+
at += point.length;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const next = chunk[at + 1];
|
|
598
|
+
if (next === "[") {
|
|
599
|
+
let end = at + 2;
|
|
600
|
+
while (end < chunk.length && !_CSI_END.test(chunk[end]!)) end++;
|
|
601
|
+
// A sequence cut off by the end of the read is passed through whole
|
|
602
|
+
// rather than split into an Escape and some letters, which is what would
|
|
603
|
+
// type `[` and `A` into the search box.
|
|
604
|
+
out.push(chunk.slice(at, end + 1));
|
|
605
|
+
at = end + 1;
|
|
606
|
+
} else if (next === "O" && at + 2 < chunk.length) {
|
|
607
|
+
out.push(`\x1b[${chunk[at + 2]}`);
|
|
608
|
+
at += 3;
|
|
609
|
+
} else {
|
|
610
|
+
out.push("\x1b"); // A bare Escape — the key, not the start of anything.
|
|
611
|
+
at += 1;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return out;
|
|
616
|
+
}
|
|
493
617
|
|
|
494
618
|
const STATE_GLYPH: Record<DevProcessStatus["state"], string> = {
|
|
495
619
|
starting: "◌",
|
|
@@ -166,7 +166,7 @@ export class DevOrchestrator {
|
|
|
166
166
|
cwd: this._cwd,
|
|
167
167
|
env: {
|
|
168
168
|
...Bun.env,
|
|
169
|
-
|
|
169
|
+
APP_TYPE: "web",
|
|
170
170
|
// Mark the worker as developer-supervised. APP_ENV above is the
|
|
171
171
|
// runtime mode, not a deployment name, so it cannot carry this —
|
|
172
172
|
// without the flag the worker looks production-like to every
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a deployed app's security headers from the outside, and report the ones
|
|
3
|
+
* that arrive twice.
|
|
4
|
+
*
|
|
5
|
+
* A header the app sets and the proxy also sets is invisible from inside the
|
|
6
|
+
* process: the app's own view is the value it wrote, and the app is right about
|
|
7
|
+
* that. Only a request that has been through the proxy sees both. Deploying
|
|
8
|
+
* zerotal.dev turned up exactly that — `X-Frame-Options: DENY` from the proxy
|
|
9
|
+
* and `SAMEORIGIN` from the app, on the same response — and browsers do not
|
|
10
|
+
* agree on which one wins. A security control that applies inconsistently is
|
|
11
|
+
* worse than one that is simply absent, because it looks configured.
|
|
12
|
+
*
|
|
13
|
+
* ## How a duplicate is visible at all
|
|
14
|
+
*
|
|
15
|
+
* `fetch` folds repeated headers into one comma-joined value, so
|
|
16
|
+
* `X-Frame-Options` sent twice reads back as `"DENY, SAMEORIGIN"`. For headers
|
|
17
|
+
* whose grammar has no comma in it that is unambiguous evidence of a duplicate.
|
|
18
|
+
* For `Permissions-Policy` and `Referrer-Policy` it is not — a comma is
|
|
19
|
+
* legitimate syntax there — so those are deliberately not checked. A probe that
|
|
20
|
+
* cried wolf on a correct `Permissions-Policy` would be switched off within a
|
|
21
|
+
* week, and then it would not catch the `X-Frame-Options` either.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** One header's finding. */
|
|
25
|
+
export interface HeaderProbeResult {
|
|
26
|
+
/** The URL that was read. */
|
|
27
|
+
url: string;
|
|
28
|
+
/** The header, in the casing it is conventionally written. */
|
|
29
|
+
header: string;
|
|
30
|
+
/** The distinct values received, in the order sent. */
|
|
31
|
+
values: string[];
|
|
32
|
+
/** False when this needs attention. */
|
|
33
|
+
ok: boolean;
|
|
34
|
+
/** Whether the duplicated values disagree — the case browsers handle inconsistently. */
|
|
35
|
+
conflicting: boolean;
|
|
36
|
+
message: string;
|
|
37
|
+
fix?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Headers that take exactly one value, so a comma in the received value means
|
|
42
|
+
* the header was sent more than once.
|
|
43
|
+
*
|
|
44
|
+
* `Permissions-Policy`, `Referrer-Policy` and `Accept-CH` are absent on purpose:
|
|
45
|
+
* each takes a comma-separated list, so duplication is undetectable this way.
|
|
46
|
+
*/
|
|
47
|
+
const SINGLE_VALUE_HEADERS: Record<string, string> = {
|
|
48
|
+
"x-frame-options": "X-Frame-Options",
|
|
49
|
+
"x-content-type-options": "X-Content-Type-Options",
|
|
50
|
+
"strict-transport-security": "Strict-Transport-Security",
|
|
51
|
+
"cross-origin-opener-policy": "Cross-Origin-Opener-Policy",
|
|
52
|
+
"cross-origin-resource-policy": "Cross-Origin-Resource-Policy",
|
|
53
|
+
"cross-origin-embedder-policy": "Cross-Origin-Embedder-Policy",
|
|
54
|
+
"x-xss-protection": "X-XSS-Protection",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* CSP is its own case: a comma separates *whole policies*, and a browser
|
|
59
|
+
* enforces every one of them — the effective policy is their intersection. Two
|
|
60
|
+
* policies that were each written to be sufficient usually intersect into
|
|
61
|
+
* something that blocks the page.
|
|
62
|
+
*/
|
|
63
|
+
const CSP_HEADERS: Record<string, string> = {
|
|
64
|
+
"content-security-policy": "Content-Security-Policy",
|
|
65
|
+
"content-security-policy-report-only": "Content-Security-Policy-Report-Only",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Split a folded header value into the values that were actually sent. */
|
|
69
|
+
export function splitFolded(value: string): string[] {
|
|
70
|
+
return value
|
|
71
|
+
.split(",")
|
|
72
|
+
.map((part) => part.trim())
|
|
73
|
+
.filter((part) => part.length > 0);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Inspect a set of response headers for duplicates.
|
|
78
|
+
*
|
|
79
|
+
* Exported separately from {@link probeHeaders} so the analysis can be tested
|
|
80
|
+
* without a network round-trip — the fetch is the only part that needs one.
|
|
81
|
+
*/
|
|
82
|
+
export function analyseHeaders(url: string, headers: Headers): HeaderProbeResult[] {
|
|
83
|
+
const findings: HeaderProbeResult[] = [];
|
|
84
|
+
|
|
85
|
+
for (const [key, label] of Object.entries(SINGLE_VALUE_HEADERS)) {
|
|
86
|
+
const raw = headers.get(key);
|
|
87
|
+
if (raw === null) continue;
|
|
88
|
+
const values = splitFolded(raw);
|
|
89
|
+
if (values.length < 2) continue;
|
|
90
|
+
|
|
91
|
+
const distinct = [...new Set(values.map((value) => value.toLowerCase()))];
|
|
92
|
+
if (distinct.length > 1) {
|
|
93
|
+
findings.push({
|
|
94
|
+
url,
|
|
95
|
+
header: label,
|
|
96
|
+
values,
|
|
97
|
+
ok: false,
|
|
98
|
+
conflicting: true,
|
|
99
|
+
message:
|
|
100
|
+
`sent ${values.length} times with different values (${values.join(" / ")}). ` +
|
|
101
|
+
`Browsers do not agree on which one applies, so this control is enforced ` +
|
|
102
|
+
`inconsistently across your visitors.`,
|
|
103
|
+
fix:
|
|
104
|
+
`Set ${label} in exactly one place — either the app (config/app.ts → ` +
|
|
105
|
+
`app.secureHeaders) or the proxy — and remove the other.`,
|
|
106
|
+
});
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
findings.push({
|
|
111
|
+
url,
|
|
112
|
+
header: label,
|
|
113
|
+
values,
|
|
114
|
+
ok: false,
|
|
115
|
+
conflicting: false,
|
|
116
|
+
message:
|
|
117
|
+
`sent ${values.length} times with the same value (${values[0]}). Harmless today, ` +
|
|
118
|
+
`and a conflict the moment either side is changed without the other.`,
|
|
119
|
+
fix: `Remove the duplicate — keep ${label} in one place.`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const [key, label] of Object.entries(CSP_HEADERS)) {
|
|
124
|
+
const raw = headers.get(key);
|
|
125
|
+
if (raw === null) continue;
|
|
126
|
+
// A comma inside a policy is not valid in the directives apps actually use,
|
|
127
|
+
// so one here means a second policy was appended.
|
|
128
|
+
const policies = splitFolded(raw);
|
|
129
|
+
if (policies.length < 2) continue;
|
|
130
|
+
|
|
131
|
+
findings.push({
|
|
132
|
+
url,
|
|
133
|
+
header: label,
|
|
134
|
+
values: policies,
|
|
135
|
+
ok: false,
|
|
136
|
+
conflicting: true,
|
|
137
|
+
message:
|
|
138
|
+
`${policies.length} separate policies were sent. A browser enforces all of them at ` +
|
|
139
|
+
`once, so the policy in force is their intersection — usually stricter than either ` +
|
|
140
|
+
`author intended, and a page that breaks for no visible reason.`,
|
|
141
|
+
fix: `Send one ${label}, from the app or the proxy but not both.`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return findings;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Fetch `url` and report every duplicated security header on the response.
|
|
150
|
+
*
|
|
151
|
+
* Returns an empty array when the request fails: an unreachable URL is the
|
|
152
|
+
* transport probe's finding to make, and reporting it twice would be noise.
|
|
153
|
+
*/
|
|
154
|
+
export async function probeHeaders(url: string): Promise<HeaderProbeResult[]> {
|
|
155
|
+
let response: Response;
|
|
156
|
+
try {
|
|
157
|
+
// `redirect: "manual"` on purpose: a redirect's own headers are what the
|
|
158
|
+
// proxy adds, and following it would report the destination's instead.
|
|
159
|
+
response = await fetch(url, { method: "GET", redirect: "manual" });
|
|
160
|
+
} catch {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
return analyseHeaders(url, response.headers);
|
|
164
|
+
}
|
package/src/events/Emitter.ts
CHANGED
|
@@ -270,6 +270,30 @@ export class Emitter {
|
|
|
270
270
|
return (this._listeners.get(eventClass)?.length ?? 0) > 0;
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Every event with a listener, and the listeners it has, by name.
|
|
275
|
+
*
|
|
276
|
+
* The wiring between an application's events and what reacts to them is
|
|
277
|
+
* spread across every provider that calls `listen()`, so "what happens when an
|
|
278
|
+
* order is placed" is a question you answer by searching. This is that map,
|
|
279
|
+
* and it is what the inspector's Events tab draws.
|
|
280
|
+
*
|
|
281
|
+
* Names rather than classes, because the answer is read by a human or crosses
|
|
282
|
+
* a wire — and a listener class is not serialisable either way.
|
|
283
|
+
*
|
|
284
|
+
* @returns One row per event with at least one listener, sorted by name.
|
|
285
|
+
* @category Subscription
|
|
286
|
+
*/
|
|
287
|
+
registrations(): Array<{ event: string; listeners: string[] }> {
|
|
288
|
+
return [...this._listeners.entries()]
|
|
289
|
+
.filter(([, listeners]) => listeners.length > 0)
|
|
290
|
+
.map(([eventClass, listeners]) => ({
|
|
291
|
+
event: (eventClass as { name?: string }).name ?? String(eventClass),
|
|
292
|
+
listeners: listeners.map((listener) => listener.name),
|
|
293
|
+
}))
|
|
294
|
+
.sort((a, b) => a.event.localeCompare(b.event));
|
|
295
|
+
}
|
|
296
|
+
|
|
273
297
|
/**
|
|
274
298
|
* Remove every registered listener.
|
|
275
299
|
* @category Subscription
|
|
@@ -133,6 +133,34 @@ export const FrameworkEvents = {
|
|
|
133
133
|
for (const handlers of _byKind.values()) count += handlers.size;
|
|
134
134
|
return count;
|
|
135
135
|
},
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Which events currently have subscribers, and how many each has.
|
|
139
|
+
*
|
|
140
|
+
* The bus is the framework's nervous system and has been invisible: "does
|
|
141
|
+
* anything actually listen to `ModelChanged`" was a question you answered by
|
|
142
|
+
* reading every package. Sorted by name so two calls are comparable.
|
|
143
|
+
*
|
|
144
|
+
* Class- and kind-keyed subscriptions are merged, because a subscriber that
|
|
145
|
+
* listened by string and one that imported the class are subscribed to the
|
|
146
|
+
* same event and a reader does not care which door they came through.
|
|
147
|
+
*
|
|
148
|
+
* @returns One row per event with at least one live handler.
|
|
149
|
+
* @category Subscription
|
|
150
|
+
*/
|
|
151
|
+
subscriptions(): Array<{ event: string; handlers: number }> {
|
|
152
|
+
const counts = new Map<string, number>();
|
|
153
|
+
for (const [ctor, handlers] of _byClass) {
|
|
154
|
+
if (handlers.size)
|
|
155
|
+
counts.set(_kindOf(ctor), (counts.get(_kindOf(ctor)) ?? 0) + handlers.size);
|
|
156
|
+
}
|
|
157
|
+
for (const [kind, handlers] of _byKind) {
|
|
158
|
+
if (handlers.size) counts.set(kind, (counts.get(kind) ?? 0) + handlers.size);
|
|
159
|
+
}
|
|
160
|
+
return [...counts.entries()]
|
|
161
|
+
.map(([event, handlers]) => ({ event, handlers }))
|
|
162
|
+
.sort((a, b) => a.event.localeCompare(b.event));
|
|
163
|
+
},
|
|
136
164
|
};
|
|
137
165
|
|
|
138
166
|
// ── Framework event types ─────────────────────────────────────────────────────
|
|
@@ -218,6 +246,20 @@ export class RequestFailed {
|
|
|
218
246
|
readonly durationMs: number,
|
|
219
247
|
readonly error: string,
|
|
220
248
|
readonly status: number,
|
|
249
|
+
/**
|
|
250
|
+
* The error's class name, when the failure was an `Error`.
|
|
251
|
+
*
|
|
252
|
+
* `message` alone cannot tell a `ValidationError` from a `TypeError`, and
|
|
253
|
+
* which one it was is usually the first thing you want to know.
|
|
254
|
+
*/
|
|
255
|
+
readonly type?: string,
|
|
256
|
+
/**
|
|
257
|
+
* The raw `Error.stack`, for subscribers that render a trace.
|
|
258
|
+
*
|
|
259
|
+
* Carried as the unparsed string: the shape differs between runtimes, and
|
|
260
|
+
* this event should not be the thing that decides how a frame is spelled.
|
|
261
|
+
*/
|
|
262
|
+
readonly stack?: string,
|
|
221
263
|
) {}
|
|
222
264
|
}
|
|
223
265
|
|
package/src/helpers/index.ts
CHANGED
|
@@ -22,7 +22,11 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { join } from "node:path";
|
|
24
24
|
import { ConfigError } from "../errors/ConfigError.ts";
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
DEPLOY_ENV_VAR,
|
|
27
|
+
RUNTIME_MODES as _RUNTIME_MODES,
|
|
28
|
+
RUNTIME_MODE_VAR as _RUNTIME_MODE_VAR,
|
|
29
|
+
} from "../support/env.ts";
|
|
26
30
|
|
|
27
31
|
// ── basePath() ────────────────────────────────────────────────────────────────
|
|
28
32
|
|
|
@@ -46,13 +50,19 @@ export function basePath(...segments: string[]): string {
|
|
|
46
50
|
// ── setAppEnv() ───────────────────────────────────────────────────────────────
|
|
47
51
|
|
|
48
52
|
/**
|
|
49
|
-
* Set `
|
|
50
|
-
* the dynamic import of `bootstrap/app.ts
|
|
51
|
-
* the
|
|
53
|
+
* Set `APP_TYPE` — the runtime mode — from the CLI command name. Call it in
|
|
54
|
+
* `zerotal.ts` BEFORE the dynamic import of `bootstrap/app.ts`, so
|
|
55
|
+
* `Application.create()` sees the mode it should boot providers for.
|
|
52
56
|
*
|
|
53
|
-
*
|
|
57
|
+
* **`APP_ENV` is not touched.** That variable holds the deployment name the
|
|
58
|
+
* operator set, and it is what answers "is this production?". The two used to
|
|
59
|
+
* share one variable and the mode won, so `APP_ENV=production` read back as
|
|
60
|
+
* `"console"` in every CLI command — see {@link RUNTIME_MODE_VAR}.
|
|
54
61
|
*
|
|
55
|
-
*
|
|
62
|
+
* An `APP_TYPE` already in the environment wins over the command, which is how
|
|
63
|
+
* the dev orchestrator boots a supervised server as `web`.
|
|
64
|
+
*
|
|
65
|
+
* | Command | APP_TYPE |
|
|
56
66
|
* |----------------------|-----------|
|
|
57
67
|
* | serve / start / s | web |
|
|
58
68
|
* | dev / d | web |
|
|
@@ -71,41 +81,46 @@ export function setAppEnv(command?: string): void {
|
|
|
71
81
|
const current = Bun.env["APP_ENV"];
|
|
72
82
|
const environment = Bun.env as Record<string, string>;
|
|
73
83
|
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
84
|
+
// `APP_ENV` is left alone. It holds the deployment name the operator set —
|
|
85
|
+
// `production`, `staging`, `local` — and it is the only place that answers
|
|
86
|
+
// "is this production?". This function used to overwrite it with the runtime
|
|
87
|
+
// mode, which is why six gates asking that question of `Bun.env["APP_ENV"]`
|
|
88
|
+
// read `"web"` and quietly answered no, including the weak-`APP_KEY` refusal
|
|
89
|
+
// and the ORM's N+1 detector; a preserved copy patched those, and then
|
|
90
|
+
// `env("APP_ENV")` still returned `"console"` inside a seeder, because the
|
|
91
|
+
// copy was only ever read through `deployEnv()`.
|
|
79
92
|
//
|
|
80
|
-
//
|
|
81
|
-
// when it is a genuine deployment name, so a second `setAppEnv` — which sees the
|
|
82
|
-
// runtime mode this one just wrote — cannot stamp `"web"` over `"production"`.
|
|
93
|
+
// Two questions, two variables. The mode goes to `APP_TYPE`.
|
|
83
94
|
if (current && !_RUNTIME_MODES.has(current.toLowerCase())) {
|
|
95
|
+
// Still mirrored, for anything already reading the preserved copy.
|
|
84
96
|
environment[DEPLOY_ENV_VAR] = current;
|
|
85
97
|
}
|
|
86
98
|
|
|
99
|
+
// Deliberately no attempt to "migrate" a legacy `APP_ENV=web` into `APP_TYPE`
|
|
100
|
+
// and put a deployment name back. `RUNTIME_MODES` contains `test` — which
|
|
101
|
+
// `zt test` sets as a *deployment* name — so the two sets overlap and any
|
|
102
|
+
// rewrite here would eventually clobber the one thing this change exists to
|
|
103
|
+
// protect. `runtimeMode()` reads the legacy location instead, where a wrong
|
|
104
|
+
// guess costs a mode rather than an environment.
|
|
105
|
+
|
|
106
|
+
// An explicit `APP_TYPE` wins: it is how the dev orchestrator tells the server
|
|
107
|
+
// it supervises to boot as `web` regardless of the command that started it.
|
|
108
|
+
const explicit = environment[_RUNTIME_MODE_VAR];
|
|
109
|
+
if (explicit && _RUNTIME_MODES.has(explicit.toLowerCase())) return;
|
|
110
|
+
|
|
87
111
|
if (["serve", "start", "s", "dev", "d"].includes(normalizedCommand)) {
|
|
88
|
-
// Always force web mode for the HTTP server — deployment-env names like
|
|
89
|
-
// "local" or "production" must not leave the app in console mode.
|
|
90
|
-
//
|
|
91
112
|
// `dev` belongs here with `serve`, and the reason is not cosmetic. Dev mode's
|
|
92
113
|
// process 1 boots the app purely to ask its providers what to run, and a
|
|
93
|
-
// provider is only asked if `static environments` includes the
|
|
114
|
+
// provider is only asked if `static environments` includes the mode it booted
|
|
94
115
|
// under. Falling through to "console" below would silently drop every
|
|
95
116
|
// web-only provider — no error, no empty tab, just a process that never
|
|
96
117
|
// appears — and would make `zt dev` and `serve --dev` disagree about what
|
|
97
118
|
// dev mode consists of.
|
|
98
|
-
|
|
99
|
-
environment["APP_ENV"] = "web";
|
|
100
|
-
}
|
|
119
|
+
environment[_RUNTIME_MODE_VAR] = "web";
|
|
101
120
|
} else if (["worker", "queue:work"].includes(normalizedCommand)) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
} else if (!current || !_RUNTIME_MODES.has(current.toLowerCase())) {
|
|
106
|
-
// Mirror the serve/worker branches: a deployment-env name like "local" or
|
|
107
|
-
// "production" must not leave the app in web mode for a CLI command.
|
|
108
|
-
environment["APP_ENV"] = "console";
|
|
121
|
+
environment[_RUNTIME_MODE_VAR] = "worker";
|
|
122
|
+
} else {
|
|
123
|
+
environment[_RUNTIME_MODE_VAR] = "console";
|
|
109
124
|
}
|
|
110
125
|
}
|
|
111
126
|
|
package/src/index.ts
CHANGED
|
@@ -66,6 +66,7 @@ export type {
|
|
|
66
66
|
FileRoutingEntry,
|
|
67
67
|
FileRoutingConfig,
|
|
68
68
|
AppScopeInstaller,
|
|
69
|
+
ProviderReport,
|
|
69
70
|
} from "./application/Application.ts";
|
|
70
71
|
export { ExceptionHandler } from "./application/ExceptionHandler.ts";
|
|
71
72
|
|
|
@@ -161,6 +162,7 @@ export {
|
|
|
161
162
|
export { config } from "./helpers/config.ts";
|
|
162
163
|
export { pluralize, singularize, snakeCase, camelCase, tableNameFor } from "./support/str.ts";
|
|
163
164
|
export { deepMerge } from "./support/deepMerge.ts";
|
|
165
|
+
export type { DeepPartial } from "./support/deepMerge.ts";
|
|
164
166
|
// The type every class-keyed registry uses — a class rather than an instance.
|
|
165
167
|
export type { ClassRef } from "./support/classRef.ts";
|
|
166
168
|
export { safeEqual, sha256Hex, hmacHex } from "./support/crypto.ts";
|