@piwitests/instrumentation-nitro 0.36.0 → 0.38.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/README.md +61 -2
- package/dist/faults.d.ts +46 -0
- package/dist/faults.js +119 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +200 -3
- package/dist/manifest.d.ts +32 -0
- package/dist/manifest.js +56 -0
- package/dist/probe.d.ts +54 -0
- package/dist/probe.js +87 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -4,9 +4,9 @@ Nitro / Nuxt server plugin for [Piwi Dashboard](https://piwitests.dev) — captu
|
|
|
4
4
|
|
|
5
5
|
During a Playwright test run, the reporter reads this header from every response and stores the entries alongside the network request. The entries are then available in the Piwi Dashboard test-case view and are included in the AI diagnosis context.
|
|
6
6
|
|
|
7
|
-
**Active
|
|
7
|
+
**Active in development and test only, by default.** Capture and probe verification run only outside production, using the same Development/Test allow-list as the ASP.NET package. Controlled by the `PIWI_TEST_LOGS_DISABLED` environment variable:
|
|
8
8
|
|
|
9
|
-
- unset — capture is on
|
|
9
|
+
- unset — capture is on when `NODE_ENV` is unset, `development`, or `test`; off for any other value (`production`, `staging`, `prod`, …)
|
|
10
10
|
- `PIWI_TEST_LOGS_DISABLED=true` — capture is off everywhere
|
|
11
11
|
- `PIWI_TEST_LOGS_DISABLED=false` — capture is on even in production builds (useful for a production-mode test deployment)
|
|
12
12
|
|
|
@@ -71,6 +71,65 @@ The plugin wraps Nitro's root H3 handler and uses three mechanisms:
|
|
|
71
71
|
2. **`AsyncLocalStorage.run()`** — scopes that buffer around the entire downstream chain (hooks, middleware, route handlers), so the process-global `consola` reporter always appends to the correct request's buffer.
|
|
72
72
|
3. **A patched `res.end`** — the header is written just before the response goes out, which covers **every** response, including H3 error responses that bypass Nitro's `beforeResponse` hook. Right before writing, unhandled errors are drained from `event.context.nitro.errors`, so thrown errors appear even when nothing logged via consola.
|
|
73
73
|
|
|
74
|
+
## Server probes — the `X-Piwi-Probe` header
|
|
75
|
+
|
|
76
|
+
The plugin accepts a signed fault instruction from a Piwi probe run (Test Map,
|
|
77
|
+
level two), so a passing test can be checked against the server's real error
|
|
78
|
+
path. A verified header is always recorded on the request scope
|
|
79
|
+
(`event.context._piwiProbe`); a fault is **applied** only once a project turns
|
|
80
|
+
server probes on (`PIWI_SERVER_PROBES=true`), which stays off by default.
|
|
81
|
+
|
|
82
|
+
The header is honored only outside production, under the **same guard as log
|
|
83
|
+
capture** (`PIWI_TEST_LOGS_DISABLED`), and only when a shared secret is set:
|
|
84
|
+
|
|
85
|
+
| Variable | Effect |
|
|
86
|
+
|----------------------|-------------------------------------------------------------------|
|
|
87
|
+
| `PIWI_PROBE_SECRET` | Shared HMAC secret. When unset, the probe header is ignored. |
|
|
88
|
+
| `PIWI_SERVER_PROBES` | `true` to apply verified faults to the signed request. |
|
|
89
|
+
|
|
90
|
+
**Faults applied** (to the one request the probe run signs — the reporter picks
|
|
91
|
+
the Nth match and signs that request, so the plugin applies the fault to any
|
|
92
|
+
request the header matches and the single-use nonce keeps it from repeating;
|
|
93
|
+
gated per project on the dashboard side): `throw`, `status`/`auth` (run the
|
|
94
|
+
server's error path with a
|
|
95
|
+
500/401), `delay`/`slow`/`slow-first` (+5s), `extreme` (empty default),
|
|
96
|
+
`data`/`drop-field`/`empty-body` (mutate the response before serialization), and
|
|
97
|
+
`dependency` (fail one outbound `$fetch` whose URL names the targeted
|
|
98
|
+
dependency; when no call matches, nothing is applied). The plugin reports
|
|
99
|
+
the fault it actually applied in `X-Piwi-Trace` (root-span `piwi.probe.applied`),
|
|
100
|
+
so a probe the server did not honor is recorded as **inconclusive**, never a pass.
|
|
101
|
+
|
|
102
|
+
**Entry condition.** Turn this on when the client probes (level one) report
|
|
103
|
+
"not-noticed" on at least one in ten probed pairs across your pilot projects —
|
|
104
|
+
that is the signal the suite has false comfort the network boundary cannot reveal.
|
|
105
|
+
|
|
106
|
+
**Header format.** `X-Piwi-Probe` carries a base64-encoded JSON envelope:
|
|
107
|
+
|
|
108
|
+
```jsonc
|
|
109
|
+
{
|
|
110
|
+
"nonce": "<hex, single use>",
|
|
111
|
+
"ts": 1700000000000, // issued-at, Unix ms; honored within 60s
|
|
112
|
+
"specJson": "{\"route\":\"POST /api/orders\",\"fault\":\"status-500\",\"nth\":1}",
|
|
113
|
+
"sig": "<hex HMAC-SHA256>" // over `${nonce}.${ts}.${specJson}` with PIWI_PROBE_SECRET
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`specJson` is the exact JSON string that was signed (the plugin re-parses it
|
|
118
|
+
after the signature check, so no canonicalization is needed). A verified spec is
|
|
119
|
+
exposed on `event.context._piwiProbe` for future handler use. The signing and
|
|
120
|
+
verification helpers are exported (`signProbeMessage`, `verifyProbeHeader`).
|
|
121
|
+
|
|
122
|
+
> **Single-use nonces are tracked per process.** The plugin remembers honored
|
|
123
|
+
> nonces in memory to reject replays within the 60s TTL, so a header replayed to a
|
|
124
|
+
> *different* instance behind a load balancer — or after a restart — is not
|
|
125
|
+
> caught by the nonce check (the TTL and signature still bound it). Probe runs
|
|
126
|
+
> target a single instance, so this is not a concern in practice; run the probed
|
|
127
|
+
> server as one instance if you need the replay guard to be exact.
|
|
128
|
+
|
|
129
|
+
The root request span also carries the matched handler's source file as
|
|
130
|
+
`attrs['piwi.handler']` when Nitro exposes it, so the dashboard can attribute a
|
|
131
|
+
route to its handler by observation rather than by convention.
|
|
132
|
+
|
|
74
133
|
## Peer dependencies
|
|
75
134
|
|
|
76
135
|
| Package | Version |
|
package/dist/faults.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-probe fault classes (Test Map, level two) — the pure decisions the
|
|
3
|
+
* plugin applies to one signed request. Kept side-effect-free so each class is
|
|
4
|
+
* unit-tested without a running server; the plugin owns the throwing, delaying
|
|
5
|
+
* and outbound-call interception these describe.
|
|
6
|
+
*/
|
|
7
|
+
/** A verified probe fault instruction for one request. */
|
|
8
|
+
export interface ProbeFaultSpec {
|
|
9
|
+
/** Route key `METHOD /pattern` the fault targets, when scoped to one route. */
|
|
10
|
+
route?: string;
|
|
11
|
+
/** The fault class to apply. */
|
|
12
|
+
fault: string;
|
|
13
|
+
/** Apply to the Nth matching request (1-based); defaults to 1. */
|
|
14
|
+
nth?: number;
|
|
15
|
+
/** Dependency name a dependency fault targets, when scoped. */
|
|
16
|
+
dependency?: string;
|
|
17
|
+
}
|
|
18
|
+
/** The HTTP status a fault forces on the response, or null when it forces none. */
|
|
19
|
+
export declare function faultStatus(fault: string): number | null;
|
|
20
|
+
/** The delay (ms) a timing fault adds before the response, or 0 for none. */
|
|
21
|
+
export declare function faultDelayMs(fault: string): number;
|
|
22
|
+
/** A handler fault throws inside the handler so the server's error path runs. */
|
|
23
|
+
export declare function isThrowFault(fault: string): boolean;
|
|
24
|
+
/** An extreme fault returns the handler's empty/default value. */
|
|
25
|
+
export declare function isExtremeFault(fault: string): boolean;
|
|
26
|
+
/** A data fault mutates the response object before serialization. */
|
|
27
|
+
export declare function isDataFault(fault: string): boolean;
|
|
28
|
+
/** A dependency fault fails or delays one outbound call the handler makes. */
|
|
29
|
+
export declare function isDependencyFault(fault: string): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Apply a data fault to a response body before serialization: `empty-body`
|
|
32
|
+
* empties it, `drop-field` removes the first own key of an object, `data` nulls
|
|
33
|
+
* the first own key. Anything else is returned unchanged.
|
|
34
|
+
*/
|
|
35
|
+
export declare function mutateResponseBody(fault: string, body: unknown): unknown;
|
|
36
|
+
/**
|
|
37
|
+
* True when a probe spec's route targets this request. An unscoped spec (no
|
|
38
|
+
* route) matches every request. The reporter signs the header onto exactly the
|
|
39
|
+
* request it wants faulted, so a route match is the whole selector server-side —
|
|
40
|
+
* the `nth` occurrence is chosen by the reporter, never re-counted here.
|
|
41
|
+
*/
|
|
42
|
+
export declare function routeMatchesRequest(spec: ProbeFaultSpec, method: string, path: string): boolean;
|
|
43
|
+
/** True when an outbound call's target names the dependency a fault is scoped to. */
|
|
44
|
+
export declare function dependencyCallMatches(dependency: string | undefined, target: string): boolean;
|
|
45
|
+
/** The label reported in X-Piwi-Trace for the fault the server actually applied. */
|
|
46
|
+
export declare function appliedFaultLabel(spec: ProbeFaultSpec): string;
|
package/dist/faults.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-probe fault classes (Test Map, level two) — the pure decisions the
|
|
3
|
+
* plugin applies to one signed request. Kept side-effect-free so each class is
|
|
4
|
+
* unit-tested without a running server; the plugin owns the throwing, delaying
|
|
5
|
+
* and outbound-call interception these describe.
|
|
6
|
+
*/
|
|
7
|
+
const DELAY_MS = 5000;
|
|
8
|
+
/** The HTTP status a fault forces on the response, or null when it forces none. */
|
|
9
|
+
export function faultStatus(fault) {
|
|
10
|
+
switch (fault) {
|
|
11
|
+
case 'status':
|
|
12
|
+
case 'status-500':
|
|
13
|
+
return 500;
|
|
14
|
+
case 'auth':
|
|
15
|
+
return 401;
|
|
16
|
+
default:
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** The delay (ms) a timing fault adds before the response, or 0 for none. */
|
|
21
|
+
export function faultDelayMs(fault) {
|
|
22
|
+
return fault === 'delay' || fault === 'slow' || fault === 'slow-first' ? DELAY_MS : 0;
|
|
23
|
+
}
|
|
24
|
+
/** A handler fault throws inside the handler so the server's error path runs. */
|
|
25
|
+
export function isThrowFault(fault) {
|
|
26
|
+
return fault === 'throw';
|
|
27
|
+
}
|
|
28
|
+
/** An extreme fault returns the handler's empty/default value. */
|
|
29
|
+
export function isExtremeFault(fault) {
|
|
30
|
+
return fault === 'extreme';
|
|
31
|
+
}
|
|
32
|
+
/** A data fault mutates the response object before serialization. */
|
|
33
|
+
export function isDataFault(fault) {
|
|
34
|
+
return fault === 'data' || fault === 'drop-field' || fault === 'empty-body';
|
|
35
|
+
}
|
|
36
|
+
/** A dependency fault fails or delays one outbound call the handler makes. */
|
|
37
|
+
export function isDependencyFault(fault) {
|
|
38
|
+
return fault === 'dependency';
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Apply a data fault to a response body before serialization: `empty-body`
|
|
42
|
+
* empties it, `drop-field` removes the first own key of an object, `data` nulls
|
|
43
|
+
* the first own key. Anything else is returned unchanged.
|
|
44
|
+
*/
|
|
45
|
+
export function mutateResponseBody(fault, body) {
|
|
46
|
+
if (fault === 'empty-body')
|
|
47
|
+
return Array.isArray(body) ? [] : typeof body === 'string' ? '' : {};
|
|
48
|
+
if (!body || typeof body !== 'object')
|
|
49
|
+
return body;
|
|
50
|
+
if (Array.isArray(body))
|
|
51
|
+
return fault === 'drop-field' ? [] : body;
|
|
52
|
+
const keys = Object.keys(body);
|
|
53
|
+
if (keys.length === 0)
|
|
54
|
+
return body;
|
|
55
|
+
const clone = { ...body };
|
|
56
|
+
const first = keys[0];
|
|
57
|
+
if (fault === 'drop-field')
|
|
58
|
+
delete clone[first];
|
|
59
|
+
else if (fault === 'data')
|
|
60
|
+
clone[first] = null;
|
|
61
|
+
return clone;
|
|
62
|
+
}
|
|
63
|
+
/** Split a route key into method + path pattern (`POST /api/orders` → …). */
|
|
64
|
+
function parseRouteKey(routeKey) {
|
|
65
|
+
const space = routeKey.indexOf(' ');
|
|
66
|
+
if (space < 0)
|
|
67
|
+
return { method: '', pattern: routeKey };
|
|
68
|
+
return { method: routeKey.slice(0, space).toUpperCase(), pattern: routeKey.slice(space + 1) };
|
|
69
|
+
}
|
|
70
|
+
/** True when a concrete request matches a route pattern with `:param` segments. */
|
|
71
|
+
function pathMatchesPattern(pattern, path) {
|
|
72
|
+
const p = (pattern.split('?')[0] ?? pattern).split('/').filter(Boolean);
|
|
73
|
+
const a = (path.split('?')[0] ?? path).split('/').filter(Boolean);
|
|
74
|
+
if (p.length !== a.length)
|
|
75
|
+
return false;
|
|
76
|
+
for (let i = 0; i < p.length; i++) {
|
|
77
|
+
const seg = p[i];
|
|
78
|
+
if (seg.startsWith(':') || (seg.startsWith('[') && seg.endsWith(']')) || (seg.startsWith('{') && seg.endsWith('}')))
|
|
79
|
+
continue;
|
|
80
|
+
if (seg !== a[i])
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* True when a probe spec's route targets this request. An unscoped spec (no
|
|
87
|
+
* route) matches every request. The reporter signs the header onto exactly the
|
|
88
|
+
* request it wants faulted, so a route match is the whole selector server-side —
|
|
89
|
+
* the `nth` occurrence is chosen by the reporter, never re-counted here.
|
|
90
|
+
*/
|
|
91
|
+
export function routeMatchesRequest(spec, method, path) {
|
|
92
|
+
if (!spec.route)
|
|
93
|
+
return true;
|
|
94
|
+
const { method: m, pattern } = parseRouteKey(spec.route);
|
|
95
|
+
if (m && m !== method.toUpperCase())
|
|
96
|
+
return false;
|
|
97
|
+
return pathMatchesPattern(pattern, path);
|
|
98
|
+
}
|
|
99
|
+
/** True when an outbound call's target names the dependency a fault is scoped to. */
|
|
100
|
+
export function dependencyCallMatches(dependency, target) {
|
|
101
|
+
if (!dependency)
|
|
102
|
+
return true;
|
|
103
|
+
if (!target)
|
|
104
|
+
return false;
|
|
105
|
+
const needle = dependency.toLowerCase();
|
|
106
|
+
const hay = target.toLowerCase();
|
|
107
|
+
if (hay.includes(needle))
|
|
108
|
+
return true;
|
|
109
|
+
try {
|
|
110
|
+
return new URL(target).host.toLowerCase().includes(needle);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** The label reported in X-Piwi-Trace for the fault the server actually applied. */
|
|
117
|
+
export function appliedFaultLabel(spec) {
|
|
118
|
+
return spec.dependency ? `${spec.fault}:${spec.dependency}` : spec.fault;
|
|
119
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { NitroAppPlugin } from 'nitropack';
|
|
2
|
+
export { verifyProbeHeader, signProbeMessage, probeSigningMessage, PROBE_TTL_MS, type PiwiProbeSpec, type SignedProbe, } from './probe';
|
|
3
|
+
export { buildRouteManifest, recordObservedRoute, collapsePathPattern, type RouteManifest } from './manifest';
|
|
4
|
+
export * from './faults';
|
|
2
5
|
export interface PiwiTestLogEntry {
|
|
3
6
|
timestamp: number;
|
|
4
7
|
level: string;
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,13 @@ import { AsyncLocalStorage } from 'node:async_hooks';
|
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
3
|
import { gzipSync } from 'node:zlib';
|
|
4
4
|
import { consola } from 'consola';
|
|
5
|
+
import { createError } from 'h3';
|
|
6
|
+
import { verifyProbeHeader, ProbeNonceCache } from './probe';
|
|
7
|
+
import { buildRouteManifest, recordObservedRoute, collapsePathPattern, } from './manifest';
|
|
8
|
+
import { routeMatchesRequest, faultStatus, faultDelayMs, isThrowFault, isExtremeFault, isDataFault, isDependencyFault, dependencyCallMatches, mutateResponseBody, appliedFaultLabel, } from './faults';
|
|
9
|
+
export { verifyProbeHeader, signProbeMessage, probeSigningMessage, PROBE_TTL_MS, } from './probe';
|
|
10
|
+
export { buildRouteManifest, recordObservedRoute, collapsePathPattern } from './manifest';
|
|
11
|
+
export * from './faults';
|
|
5
12
|
const MAX_ENTRIES = 50;
|
|
6
13
|
const MAX_MSG_LENGTH = 500;
|
|
7
14
|
const MAX_STACK_FRAMES = 5;
|
|
@@ -40,6 +47,17 @@ function parseTraceparent(tp) {
|
|
|
40
47
|
return parts[1];
|
|
41
48
|
return undefined;
|
|
42
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Record the fault the server actually applied for this request, so X-Piwi-Trace
|
|
52
|
+
* carries it and the reporter can compare it with the fault it asked for. Called
|
|
53
|
+
* only at the point a fault takes effect, never on a bare route match.
|
|
54
|
+
*/
|
|
55
|
+
function markProbeApplied(store) {
|
|
56
|
+
if (store.probe)
|
|
57
|
+
store.probeApplied = appliedFaultLabel(store.probe);
|
|
58
|
+
}
|
|
59
|
+
/** Signed probe nonces already honored this process, so a header is single-use. */
|
|
60
|
+
const probeNonces = new ProbeNonceCache();
|
|
43
61
|
// Links consola calls and recorded spans to the request being handled. The store
|
|
44
62
|
// is scoped with als.run() around the whole downstream handler chain —
|
|
45
63
|
// enterWith() from a request hook is not reliable here (the binding dies with
|
|
@@ -58,8 +76,43 @@ export function recordServerSpan(span) {
|
|
|
58
76
|
}
|
|
59
77
|
// The consola reporter is process-global — register it only once.
|
|
60
78
|
let reporterAdded = false;
|
|
79
|
+
// Capture and probe verification run only outside production, matching the
|
|
80
|
+
// ASP.NET package's Development/Test allow-list: only an unset/empty NODE_ENV or
|
|
81
|
+
// `development`/`test` is treated as non-production. Any other value
|
|
82
|
+
// (`production`, `staging`, `prod`, …) disables capture unless
|
|
83
|
+
// PIWI_TEST_LOGS_DISABLED is explicitly `false`, so a signed probe header is
|
|
84
|
+
// never honored on a production-like deployment that skipped `NODE_ENV=production`.
|
|
85
|
+
const NODE_ENV = process.env.NODE_ENV;
|
|
86
|
+
const IS_DEV_OR_TEST = !NODE_ENV || NODE_ENV === 'development' || NODE_ENV === 'test';
|
|
61
87
|
const TEST_LOGS_DISABLED = process.env.PIWI_TEST_LOGS_DISABLED === 'true' ||
|
|
62
|
-
(
|
|
88
|
+
(!IS_DEV_OR_TEST && process.env.PIWI_TEST_LOGS_DISABLED !== 'false');
|
|
89
|
+
/** The shared secret a probe run signs the `X-Piwi-Probe` header with. */
|
|
90
|
+
const PROBE_SECRET = process.env.PIWI_PROBE_SECRET || undefined;
|
|
91
|
+
/** The declared-surface route path the plugin serves outside production. */
|
|
92
|
+
const MANIFEST_PATH = '/__piwi/manifest';
|
|
93
|
+
/**
|
|
94
|
+
* Routes the server has matched this process, accumulated for `/__piwi/manifest`.
|
|
95
|
+
* Nitro exposes no runtime route table, so the manifest is built from observation.
|
|
96
|
+
*/
|
|
97
|
+
const observedRoutes = new Map();
|
|
98
|
+
/**
|
|
99
|
+
* Server probes stay off unless a project opts in. When off (the default in this
|
|
100
|
+
* milestone) a signed probe header is still verified and recorded, but no fault
|
|
101
|
+
* is applied — only the client-safe subset would ever be, and only once this is
|
|
102
|
+
* on.
|
|
103
|
+
*/
|
|
104
|
+
const SERVER_PROBES_ENABLED = process.env.PIWI_SERVER_PROBES === 'true';
|
|
105
|
+
/**
|
|
106
|
+
* Best-effort source file of the matched route handler, for the root span's
|
|
107
|
+
* `piwi.handler` attribute. Nitro exposes the matched route on the event context
|
|
108
|
+
* on recent versions; when it carries no file, the attribute is omitted and the
|
|
109
|
+
* dashboard falls back to its file-routing convention.
|
|
110
|
+
*/
|
|
111
|
+
function resolveHandlerFile(event) {
|
|
112
|
+
const matched = event?.context?.matchedRoute;
|
|
113
|
+
const file = matched?.file ?? matched?.filename;
|
|
114
|
+
return typeof file === 'string' && file ? file : undefined;
|
|
115
|
+
}
|
|
63
116
|
const piwiTestLogs = (nitroApp) => {
|
|
64
117
|
if (TEST_LOGS_DISABLED)
|
|
65
118
|
return;
|
|
@@ -89,9 +142,49 @@ const piwiTestLogs = (nitroApp) => {
|
|
|
89
142
|
// als.run() scope covers every hook, middleware, and route handler.
|
|
90
143
|
const originalHandler = nitroApp.h3App.handler;
|
|
91
144
|
nitroApp.h3App.handler = ((event) => {
|
|
145
|
+
// Serve the declared-surface manifest outside production (the plugin already
|
|
146
|
+
// returned early when log capture is disabled). Built from observed routes.
|
|
147
|
+
const requestPath = String(event.path ?? event.node.req.url ?? '').split('?')[0] ?? '';
|
|
148
|
+
if (requestPath === MANIFEST_PATH) {
|
|
149
|
+
const res = event.node.res;
|
|
150
|
+
res.statusCode = 200;
|
|
151
|
+
res.setHeader('Content-Type', 'application/json');
|
|
152
|
+
res.end(JSON.stringify(buildRouteManifest(observedRoutes)));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
92
155
|
const store = { logs: [], spans: [], startMs: Date.now() };
|
|
93
156
|
event.context._piwiLogs = store.logs;
|
|
94
157
|
event.context._piwiSpans = store.spans;
|
|
158
|
+
// The beforeResponse hook fires outside this handler's async scope (it is
|
|
159
|
+
// driven by the layer above h3App.handler), so als.getStore() is unreliable
|
|
160
|
+
// there — reach the request store through the event context instead.
|
|
161
|
+
event.context._piwiStore = store;
|
|
162
|
+
// Verify a signed probe header, honored only outside production (under the
|
|
163
|
+
// same guard as log capture) and only when a shared secret is configured.
|
|
164
|
+
// The spec is recorded on the request scope, and a fault is applied only when
|
|
165
|
+
// server probes are turned on for the project and this is the Nth match.
|
|
166
|
+
const probe = verifyProbeHeader(event.node.req.headers['x-piwi-probe'], PROBE_SECRET, Date.now(), undefined, probeNonces);
|
|
167
|
+
if (probe) {
|
|
168
|
+
store.probe = probe;
|
|
169
|
+
event.context._piwiProbe = probe;
|
|
170
|
+
if (SERVER_PROBES_ENABLED) {
|
|
171
|
+
const method = String(event.method ?? event.node.req.method ?? 'GET');
|
|
172
|
+
const reqPath = String(event.path ?? event.node.req.url ?? '').split('?')[0] ?? '';
|
|
173
|
+
// The reporter signs the header onto exactly the request it wants faulted
|
|
174
|
+
// (it already chose the Nth match), so a route match is the whole
|
|
175
|
+
// selector; the header's single-use nonce keeps it from applying twice.
|
|
176
|
+
// The fault is only *marked applied* where it actually takes effect (the
|
|
177
|
+
// handler wrap, the beforeResponse hook, or the outbound-call patch), so
|
|
178
|
+
// an unimplemented fault (`replay`) or a no-op (`data` on an array body)
|
|
179
|
+
// records inconclusive, never a false gap.
|
|
180
|
+
if (routeMatchesRequest(probe, method, reqPath)) {
|
|
181
|
+
store.probeShouldApply = true;
|
|
182
|
+
// A dependency fault only applies once a matching outbound call fires.
|
|
183
|
+
if (isDependencyFault(probe.fault))
|
|
184
|
+
store.probeDependencyPending = true;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
95
188
|
// Patch res.end so the X-Piwi-Logs / X-Piwi-Trace headers are injected for
|
|
96
189
|
// ALL responses, including H3 error responses where Nitro bypasses the
|
|
97
190
|
// 'beforeResponse' hook (h3 skips onBeforeResponse once the error handler
|
|
@@ -127,6 +220,12 @@ const piwiTestLogs = (nitroApp) => {
|
|
|
127
220
|
const path = String(event.path ?? event.node.req.url ?? '').split('?')[0] ?? '';
|
|
128
221
|
const statusCode = Number(res.statusCode) || 0;
|
|
129
222
|
const traceId = parseTraceparent(event.node.req.headers['traceparent']) ?? randomBytes(16).toString('hex');
|
|
223
|
+
const handlerFile = resolveHandlerFile(event);
|
|
224
|
+
// Record this route for the declared-surface manifest. Prefer the matched
|
|
225
|
+
// route's own pattern; fall back to collapsing the concrete path.
|
|
226
|
+
const matchedPattern = event.context?.matchedRoute?.route;
|
|
227
|
+
const pattern = typeof matchedPattern === 'string' && matchedPattern ? matchedPattern : collapsePathPattern(path);
|
|
228
|
+
recordObservedRoute(observedRoutes, { method, pattern, handler: handlerFile });
|
|
130
229
|
const rootSpan = {
|
|
131
230
|
id: randomBytes(8).toString('hex'),
|
|
132
231
|
name: `${method} ${path}`.trim(),
|
|
@@ -135,7 +234,16 @@ const piwiTestLogs = (nitroApp) => {
|
|
|
135
234
|
durMs: Math.max(0, endMs - store.startMs),
|
|
136
235
|
status: statusCode >= 500 ? 'error' : 'ok',
|
|
137
236
|
traceId,
|
|
138
|
-
attrs: {
|
|
237
|
+
attrs: {
|
|
238
|
+
'http.method': method,
|
|
239
|
+
'http.route': path,
|
|
240
|
+
'http.status_code': statusCode,
|
|
241
|
+
...(handlerFile ? { 'piwi.handler': handlerFile } : {}),
|
|
242
|
+
// A probe was signed for this request; `applied` names the fault the
|
|
243
|
+
// server honored, or is absent when it was not applied (inconclusive).
|
|
244
|
+
...(store.probe ? { 'piwi.probe': store.probe.fault } : {}),
|
|
245
|
+
...(store.probeApplied ? { 'piwi.probe.applied': store.probeApplied } : {}),
|
|
246
|
+
},
|
|
139
247
|
};
|
|
140
248
|
for (const s of store.spans)
|
|
141
249
|
if (!s.parentId)
|
|
@@ -145,7 +253,96 @@ const piwiTestLogs = (nitroApp) => {
|
|
|
145
253
|
}
|
|
146
254
|
return originalEnd(...args);
|
|
147
255
|
};
|
|
148
|
-
return als.run(store, () =>
|
|
256
|
+
return als.run(store, async () => {
|
|
257
|
+
// Apply the handler and pipeline faults inside the request scope. Delay
|
|
258
|
+
// faults slow the response; throw/status/auth run the server's error path;
|
|
259
|
+
// extreme returns the empty default. Each is marked applied only once it
|
|
260
|
+
// takes effect. Data and dependency faults are applied later (the
|
|
261
|
+
// beforeResponse hook and the outbound-fetch patch); an unimplemented fault
|
|
262
|
+
// (`replay`) matches none of these, so it is never marked applied.
|
|
263
|
+
const spec = store.probeShouldApply ? store.probe : undefined;
|
|
264
|
+
if (spec && !isDataFault(spec.fault) && !isDependencyFault(spec.fault)) {
|
|
265
|
+
const delayMs = faultDelayMs(spec.fault);
|
|
266
|
+
if (delayMs > 0)
|
|
267
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
268
|
+
if (isThrowFault(spec.fault)) {
|
|
269
|
+
markProbeApplied(store);
|
|
270
|
+
throw createError({ statusCode: 500, statusMessage: 'Piwi probe: injected error' });
|
|
271
|
+
}
|
|
272
|
+
const status = faultStatus(spec.fault);
|
|
273
|
+
if (status != null) {
|
|
274
|
+
markProbeApplied(store);
|
|
275
|
+
throw createError({ statusCode: status, statusMessage: `Piwi probe: injected ${status}` });
|
|
276
|
+
}
|
|
277
|
+
if (isExtremeFault(spec.fault)) {
|
|
278
|
+
markProbeApplied(store);
|
|
279
|
+
// Send the empty response ourselves: h3's toNodeListener ignores the
|
|
280
|
+
// handler's return value, so returning '' here would never end the
|
|
281
|
+
// node response and the probed request would hang until its timeout.
|
|
282
|
+
res.statusCode = 200;
|
|
283
|
+
res.end('');
|
|
284
|
+
return '';
|
|
285
|
+
}
|
|
286
|
+
// A pure delay fault took effect once the delay elapsed.
|
|
287
|
+
if (delayMs > 0)
|
|
288
|
+
markProbeApplied(store);
|
|
289
|
+
}
|
|
290
|
+
return originalHandler(event);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
// Data faults mutate the response body before it is serialized and sent. The
|
|
294
|
+
// fault is marked applied only when the body actually changed — `data` on an
|
|
295
|
+
// array body, or an empty object, is left unchanged and records inconclusive.
|
|
296
|
+
nitroApp.hooks.hook('beforeResponse', (event, response) => {
|
|
297
|
+
const store = event.context?._piwiStore;
|
|
298
|
+
const probe = store?.probe;
|
|
299
|
+
if (store?.probeShouldApply && probe && isDataFault(probe.fault) && response && 'body' in response) {
|
|
300
|
+
const before = response.body;
|
|
301
|
+
const mutated = mutateResponseBody(probe.fault, before);
|
|
302
|
+
if (mutated !== before) {
|
|
303
|
+
response.body = mutated;
|
|
304
|
+
markProbeApplied(store);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
149
307
|
});
|
|
308
|
+
// Dependency faults fail one outbound call the handler makes through the
|
|
309
|
+
// instrumented `$fetch`, so the handler's own error handling runs. The fault
|
|
310
|
+
// targets the call whose URL names `spec.dependency`; when no outbound call
|
|
311
|
+
// matches, nothing is applied and the probe records inconclusive.
|
|
312
|
+
if (SERVER_PROBES_ENABLED) {
|
|
313
|
+
const g = globalThis;
|
|
314
|
+
if (typeof g.$fetch === 'function' && !g.$fetch.__piwiProbePatched) {
|
|
315
|
+
const original = g.$fetch;
|
|
316
|
+
const patched = (...args) => {
|
|
317
|
+
const store = als.getStore();
|
|
318
|
+
if (store?.probeDependencyPending &&
|
|
319
|
+
store.probe &&
|
|
320
|
+
isDependencyFault(store.probe.fault) &&
|
|
321
|
+
!store.probeDependencyConsumed &&
|
|
322
|
+
dependencyCallMatches(store.probe.dependency, outboundCallTarget(args))) {
|
|
323
|
+
store.probeDependencyConsumed = true;
|
|
324
|
+
markProbeApplied(store);
|
|
325
|
+
return Promise.reject(new Error('Piwi probe: injected dependency failure'));
|
|
326
|
+
}
|
|
327
|
+
return original(...args);
|
|
328
|
+
};
|
|
329
|
+
patched.__piwiProbePatched = true;
|
|
330
|
+
g.$fetch = Object.assign(patched, original);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
150
333
|
};
|
|
334
|
+
/** The request target of an outbound `$fetch` call, for dependency-fault matching. */
|
|
335
|
+
function outboundCallTarget(args) {
|
|
336
|
+
const first = args[0];
|
|
337
|
+
if (typeof first === 'string')
|
|
338
|
+
return first;
|
|
339
|
+
if (first instanceof URL)
|
|
340
|
+
return first.toString();
|
|
341
|
+
if (first && typeof first === 'object') {
|
|
342
|
+
const url = first.url ?? first.href;
|
|
343
|
+
if (typeof url === 'string')
|
|
344
|
+
return url;
|
|
345
|
+
}
|
|
346
|
+
return '';
|
|
347
|
+
}
|
|
151
348
|
export default piwiTestLogs;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declared-surface route manifest the plugin serves at `/__piwi/manifest`
|
|
3
|
+
* outside production. Nitro has no stable public API to enumerate its route table
|
|
4
|
+
* at runtime, so the manifest is accumulated from the routes the server actually
|
|
5
|
+
* matches — each request contributes its method, matched pattern and handler
|
|
6
|
+
* file. The Piwi reporter fetches it once at run start and uploads it, and a
|
|
7
|
+
* declared route no test reaches becomes a "declared, never hit" gap.
|
|
8
|
+
*/
|
|
9
|
+
/** One declared route: method, path pattern and the handler's source file. */
|
|
10
|
+
export interface ManifestRouteEntry {
|
|
11
|
+
method: string;
|
|
12
|
+
pattern: string;
|
|
13
|
+
handler?: string;
|
|
14
|
+
}
|
|
15
|
+
/** The manifest body served at `/__piwi/manifest`. */
|
|
16
|
+
export interface RouteManifest {
|
|
17
|
+
routes: ManifestRouteEntry[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Collapse a concrete request path to a pattern: numeric and uuid segments become
|
|
21
|
+
* `:id` / `:uuid`, so `/api/orders/42` and `/api/orders/43` fold to one route.
|
|
22
|
+
* Used only when the matched route carries no pattern of its own.
|
|
23
|
+
*/
|
|
24
|
+
export declare function collapsePathPattern(path: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* Record an observed route in the accumulator, keeping the first handler file
|
|
27
|
+
* seen for it. Bounded so a pathological app (or an unbounded set of patterns
|
|
28
|
+
* slipping through) cannot grow the map without limit.
|
|
29
|
+
*/
|
|
30
|
+
export declare function recordObservedRoute(map: Map<string, ManifestRouteEntry>, entry: ManifestRouteEntry, max?: number): void;
|
|
31
|
+
/** Build the manifest body from the accumulator, routes sorted for stable output. */
|
|
32
|
+
export declare function buildRouteManifest(map: Map<string, ManifestRouteEntry>): RouteManifest;
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declared-surface route manifest the plugin serves at `/__piwi/manifest`
|
|
3
|
+
* outside production. Nitro has no stable public API to enumerate its route table
|
|
4
|
+
* at runtime, so the manifest is accumulated from the routes the server actually
|
|
5
|
+
* matches — each request contributes its method, matched pattern and handler
|
|
6
|
+
* file. The Piwi reporter fetches it once at run start and uploads it, and a
|
|
7
|
+
* declared route no test reaches becomes a "declared, never hit" gap.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Collapse a concrete request path to a pattern: numeric and uuid segments become
|
|
11
|
+
* `:id` / `:uuid`, so `/api/orders/42` and `/api/orders/43` fold to one route.
|
|
12
|
+
* Used only when the matched route carries no pattern of its own.
|
|
13
|
+
*/
|
|
14
|
+
export function collapsePathPattern(path) {
|
|
15
|
+
const clean = path.split('?')[0] ?? path;
|
|
16
|
+
return ('/' +
|
|
17
|
+
clean
|
|
18
|
+
.split('/')
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
.map((seg) => {
|
|
21
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(seg))
|
|
22
|
+
return ':uuid';
|
|
23
|
+
if (/^\d+$/.test(seg))
|
|
24
|
+
return ':id';
|
|
25
|
+
return seg;
|
|
26
|
+
})
|
|
27
|
+
.join('/'));
|
|
28
|
+
}
|
|
29
|
+
/** The unique key for a route in the accumulator: method plus pattern. */
|
|
30
|
+
function routeKey(method, pattern) {
|
|
31
|
+
return `${method.toUpperCase()} ${pattern}`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Record an observed route in the accumulator, keeping the first handler file
|
|
35
|
+
* seen for it. Bounded so a pathological app (or an unbounded set of patterns
|
|
36
|
+
* slipping through) cannot grow the map without limit.
|
|
37
|
+
*/
|
|
38
|
+
export function recordObservedRoute(map, entry, max = 2000) {
|
|
39
|
+
if (!entry.method || !entry.pattern)
|
|
40
|
+
return;
|
|
41
|
+
const key = routeKey(entry.method, entry.pattern);
|
|
42
|
+
const existing = map.get(key);
|
|
43
|
+
if (existing) {
|
|
44
|
+
if (!existing.handler && entry.handler)
|
|
45
|
+
existing.handler = entry.handler;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (map.size >= max)
|
|
49
|
+
return;
|
|
50
|
+
map.set(key, { method: entry.method.toUpperCase(), pattern: entry.pattern, handler: entry.handler });
|
|
51
|
+
}
|
|
52
|
+
/** Build the manifest body from the accumulator, routes sorted for stable output. */
|
|
53
|
+
export function buildRouteManifest(map) {
|
|
54
|
+
const routes = [...map.values()].sort((a, b) => routeKey(a.method, a.pattern).localeCompare(routeKey(b.method, b.pattern)));
|
|
55
|
+
return { routes };
|
|
56
|
+
}
|
package/dist/probe.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-probe support (Piwi Test Map, level two). A probe run signs a fault
|
|
3
|
+
* spec into the `X-Piwi-Probe` request header; the instrumentation verifies the
|
|
4
|
+
* signature here and, when server probes are enabled, applies the fault inside
|
|
5
|
+
* the request scope. In this release the header is only parsed and verified —
|
|
6
|
+
* nothing is applied unless a project turns server probes on, which stays off by
|
|
7
|
+
* default (see the README).
|
|
8
|
+
*/
|
|
9
|
+
/** A fault the probe run asks the server to apply to one request. */
|
|
10
|
+
export interface PiwiProbeSpec {
|
|
11
|
+
/** Route pattern the fault targets, e.g. `POST /api/orders`. */
|
|
12
|
+
route?: string;
|
|
13
|
+
/** The fault to apply. The client-safe subset is applied first (see README). */
|
|
14
|
+
fault: string;
|
|
15
|
+
/** Apply only to the Nth matching request (1-based); default the first. */
|
|
16
|
+
nth?: number;
|
|
17
|
+
/** Dependency name a dependency fault targets, when scoped to one. */
|
|
18
|
+
dependency?: string;
|
|
19
|
+
}
|
|
20
|
+
/** The signed envelope carried in the `X-Piwi-Probe` header (base64 JSON). */
|
|
21
|
+
export interface SignedProbe {
|
|
22
|
+
/** Single-use nonce, hex. */
|
|
23
|
+
nonce: string;
|
|
24
|
+
/** Issued-at, Unix epoch milliseconds — the TTL is measured from here. */
|
|
25
|
+
ts: number;
|
|
26
|
+
/** The exact JSON string of the {@link PiwiProbeSpec} that was signed. */
|
|
27
|
+
specJson: string;
|
|
28
|
+
/** Hex HMAC-SHA256 over `${nonce}.${ts}.${specJson}` with the shared secret. */
|
|
29
|
+
sig: string;
|
|
30
|
+
}
|
|
31
|
+
/** Default probe header lifetime — a probe is honored within a minute of issue. */
|
|
32
|
+
export declare const PROBE_TTL_MS = 60000;
|
|
33
|
+
/** The message signed and verified for a probe: nonce, timestamp and spec JSON. */
|
|
34
|
+
export declare function probeSigningMessage(nonce: string, ts: number, specJson: string): string;
|
|
35
|
+
/** Compute the hex HMAC-SHA256 signature for a probe envelope. */
|
|
36
|
+
export declare function signProbeMessage(secret: string, nonce: string, ts: number, specJson: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* A TTL-bounded set of probe nonces already honored, so a signed header is
|
|
39
|
+
* single-use: a replay within the TTL is rejected. Entries prune lazily on each
|
|
40
|
+
* use, bounding memory to the probes seen within one TTL window.
|
|
41
|
+
*/
|
|
42
|
+
export declare class ProbeNonceCache {
|
|
43
|
+
private readonly ttlMs;
|
|
44
|
+
private readonly seen;
|
|
45
|
+
constructor(ttlMs?: number);
|
|
46
|
+
/** Record a nonce as used; returns false when it was already used within the TTL. */
|
|
47
|
+
use(nonce: string, nowMs: number): boolean;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Verify a signed probe header and return its fault spec, or null when the
|
|
51
|
+
* header is absent, malformed, past its TTL, or fails the signature check. Pure
|
|
52
|
+
* over its inputs (no environment reads) so it can be unit-tested directly.
|
|
53
|
+
*/
|
|
54
|
+
export declare function verifyProbeHeader(headerValue: string | string[] | undefined, secret: string | undefined, nowMs: number, ttlMs?: number, seen?: ProbeNonceCache): PiwiProbeSpec | null;
|
package/dist/probe.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
/** Default probe header lifetime — a probe is honored within a minute of issue. */
|
|
3
|
+
export const PROBE_TTL_MS = 60_000;
|
|
4
|
+
/** The message signed and verified for a probe: nonce, timestamp and spec JSON. */
|
|
5
|
+
export function probeSigningMessage(nonce, ts, specJson) {
|
|
6
|
+
return `${nonce}.${ts}.${specJson}`;
|
|
7
|
+
}
|
|
8
|
+
/** Compute the hex HMAC-SHA256 signature for a probe envelope. */
|
|
9
|
+
export function signProbeMessage(secret, nonce, ts, specJson) {
|
|
10
|
+
return createHmac('sha256', secret).update(probeSigningMessage(nonce, ts, specJson)).digest('hex');
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A TTL-bounded set of probe nonces already honored, so a signed header is
|
|
14
|
+
* single-use: a replay within the TTL is rejected. Entries prune lazily on each
|
|
15
|
+
* use, bounding memory to the probes seen within one TTL window.
|
|
16
|
+
*/
|
|
17
|
+
export class ProbeNonceCache {
|
|
18
|
+
constructor(ttlMs = PROBE_TTL_MS) {
|
|
19
|
+
this.ttlMs = ttlMs;
|
|
20
|
+
this.seen = new Map();
|
|
21
|
+
}
|
|
22
|
+
/** Record a nonce as used; returns false when it was already used within the TTL. */
|
|
23
|
+
use(nonce, nowMs) {
|
|
24
|
+
for (const [n, expiry] of this.seen)
|
|
25
|
+
if (expiry <= nowMs)
|
|
26
|
+
this.seen.delete(n);
|
|
27
|
+
if (this.seen.has(nonce))
|
|
28
|
+
return false;
|
|
29
|
+
this.seen.set(nonce, nowMs + this.ttlMs);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Constant-time hex-signature comparison; false on any length or format mismatch. */
|
|
34
|
+
function signaturesEqual(a, b) {
|
|
35
|
+
if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length)
|
|
36
|
+
return false;
|
|
37
|
+
try {
|
|
38
|
+
return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Verify a signed probe header and return its fault spec, or null when the
|
|
46
|
+
* header is absent, malformed, past its TTL, or fails the signature check. Pure
|
|
47
|
+
* over its inputs (no environment reads) so it can be unit-tested directly.
|
|
48
|
+
*/
|
|
49
|
+
export function verifyProbeHeader(headerValue, secret, nowMs, ttlMs = PROBE_TTL_MS, seen) {
|
|
50
|
+
if (!secret)
|
|
51
|
+
return null;
|
|
52
|
+
const raw = Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
|
53
|
+
if (!raw)
|
|
54
|
+
return null;
|
|
55
|
+
let envelope;
|
|
56
|
+
try {
|
|
57
|
+
envelope = JSON.parse(Buffer.from(raw, 'base64').toString('utf8'));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (!envelope || typeof envelope !== 'object')
|
|
63
|
+
return null;
|
|
64
|
+
const { nonce, ts, specJson, sig } = envelope;
|
|
65
|
+
if (typeof nonce !== 'string' || typeof ts !== 'number' || typeof specJson !== 'string' || typeof sig !== 'string') {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
// TTL: reject an expired or not-yet-valid (clock-skewed future) probe.
|
|
69
|
+
if (!Number.isFinite(ts) || nowMs - ts > ttlMs || ts - nowMs > ttlMs)
|
|
70
|
+
return null;
|
|
71
|
+
const expected = signProbeMessage(secret, nonce, ts, specJson);
|
|
72
|
+
if (!signaturesEqual(sig, expected))
|
|
73
|
+
return null;
|
|
74
|
+
let spec;
|
|
75
|
+
try {
|
|
76
|
+
spec = JSON.parse(specJson);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
if (!spec || typeof spec !== 'object' || typeof spec.fault !== 'string')
|
|
82
|
+
return null;
|
|
83
|
+
// Single-use: reject a replay of a nonce already honored within the TTL.
|
|
84
|
+
if (seen && !seen.use(nonce, nowMs))
|
|
85
|
+
return null;
|
|
86
|
+
return spec;
|
|
87
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@piwitests/instrumentation-nitro",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Nitro/Nuxt server plugin for sending backend logs to Piwi Dashboard via the X-Piwi-Logs response header",
|
|
5
5
|
"homepage": "https://piwitests.dev",
|
|
6
6
|
"repository": {
|
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
"scripts": {
|
|
33
33
|
"build": "tsc",
|
|
34
34
|
"dev": "tsc --watch",
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"test": "vitest run",
|
|
35
37
|
"prepublishOnly": "npm run build"
|
|
36
38
|
},
|
|
37
39
|
"files": [
|