midline-agent 0.1.9 → 0.2.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/dist/redact.js ADDED
@@ -0,0 +1,223 @@
1
+ "use strict";
2
+ /**
3
+ * Redaction happens in the host process, before an event is queued. Whatever is
4
+ * removed here never reaches a socket, a log line or the Midline server.
5
+ *
6
+ * Matching is on a normalised key — lower-cased with punctuation stripped — so
7
+ * `X-API-Key`, `api_key` and `apiKey` are all the same key.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.Redactor = exports.DEFAULT_SENSITIVE_HEADERS = exports.REDACTED = void 0;
11
+ exports.normalizeKey = normalizeKey;
12
+ exports.REDACTED = "[REDACTED]";
13
+ /** Substrings: any key containing one of these is sensitive. */
14
+ const SENSITIVE_KEY_PARTS = [
15
+ "password",
16
+ "passwd",
17
+ "passphrase",
18
+ "secret",
19
+ "token",
20
+ "apikey",
21
+ "accesskey",
22
+ "privatekey",
23
+ "authorization",
24
+ "cookie",
25
+ "session",
26
+ "credential",
27
+ "csrf",
28
+ "xsrf",
29
+ "signature",
30
+ "creditcard",
31
+ "cardnumber",
32
+ "cvv",
33
+ "cvc",
34
+ "ssn",
35
+ "socialsecurity",
36
+ ];
37
+ /** Whole keys only — as substrings these would hit words like "author" or "spinner". */
38
+ const SENSITIVE_KEYS_EXACT = new Set(["auth", "pwd", "pin", "otp", "sid", "jwt", "bearer"]);
39
+ /** Always redacted by name, even if a user-supplied list somehow unmatched them. */
40
+ exports.DEFAULT_SENSITIVE_HEADERS = [
41
+ "authorization",
42
+ "proxy-authorization",
43
+ "cookie",
44
+ "set-cookie",
45
+ "x-api-key",
46
+ "api-key",
47
+ "x-auth-token",
48
+ "x-access-token",
49
+ "x-refresh-token",
50
+ "x-csrf-token",
51
+ "x-xsrf-token",
52
+ "x-amz-security-token",
53
+ ];
54
+ const VALUE_PATTERNS = [
55
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, exports.REDACTED],
56
+ [/\b(Bearer|Basic|Digest|Token)\s+[A-Za-z0-9._~+\/=-]{8,}/gi, `$1 ${exports.REDACTED}`],
57
+ [/\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}/g, exports.REDACTED],
58
+ [/\bak_[A-Fa-f0-9]{16,}\b/g, exports.REDACTED],
59
+ [/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{10,}\b/g, exports.REDACTED],
60
+ [/\bAKIA[0-9A-Z]{16}\b/g, exports.REDACTED],
61
+ [/(\b[a-z][a-z0-9+.-]*:\/\/)[^\s\/@:]+:[^\s\/@]+@/gi, `$1${exports.REDACTED}@`],
62
+ ];
63
+ /** `key=value` / `key: value` in query strings, log lines and error messages. */
64
+ const KEY_VALUE_PAIR = /(^|[?&;,\s(\[{])([A-Za-z0-9_.%-]{1,64})(\s*[=:]\s*)("[^"]*"|'[^']*'|[^\s&#,;)\]}]+)/g;
65
+ /** `"key": value` in JSON text, including JSON that was cut off mid-value. */
66
+ const JSON_PAIR = /"([^"\\]{1,100})"\s*:\s*("(?:[^"\\]|\\.)*"?|-?\d+(?:\.\d+)?|true|false|null)/g;
67
+ function normalizeKey(key) {
68
+ return key.toLowerCase().replace(/[^a-z0-9]/g, "");
69
+ }
70
+ class Redactor {
71
+ constructor(extraFields = [], extraHeaders = []) {
72
+ this.extraKeys = extraFields.map(normalizeKey).filter(Boolean);
73
+ this.headerNames = new Set([...exports.DEFAULT_SENSITIVE_HEADERS, ...extraHeaders].map((name) => name.toLowerCase()));
74
+ }
75
+ isSensitiveKey(key) {
76
+ const normalized = normalizeKey(key);
77
+ if (!normalized)
78
+ return false;
79
+ if (SENSITIVE_KEYS_EXACT.has(normalized))
80
+ return true;
81
+ if (SENSITIVE_KEY_PARTS.some((part) => normalized.includes(part)))
82
+ return true;
83
+ return this.extraKeys.some((part) => normalized.includes(part));
84
+ }
85
+ /** Masks credentials embedded in free text: bearer tokens, JWTs, key formats, URL userinfo and query params. */
86
+ string(value, maxLength = 2048) {
87
+ let out = value.length > maxLength * 4 ? value.slice(0, maxLength * 4) : value;
88
+ for (const [pattern, replacement] of VALUE_PATTERNS) {
89
+ out = out.replace(pattern, replacement);
90
+ }
91
+ if (out.includes("=") || out.includes(":")) {
92
+ out = out.replace(KEY_VALUE_PAIR, (match, sep, key, delimiter) => {
93
+ let decoded = key;
94
+ try {
95
+ decoded = decodeURIComponent(key);
96
+ }
97
+ catch {
98
+ // keep the raw key
99
+ }
100
+ return this.isSensitiveKey(decoded) ? `${sep}${key}${delimiter}${exports.REDACTED}` : match;
101
+ });
102
+ }
103
+ if (out.includes('"')) {
104
+ out = out.replace(JSON_PAIR, (match, key) => this.isSensitiveKey(key) ? `"${key}":"${exports.REDACTED}"` : match);
105
+ }
106
+ // The ellipsis counts toward the limit: servers validate these lengths exactly.
107
+ return out.length > maxLength ? `${out.slice(0, Math.max(0, maxLength - 1))}…` : out;
108
+ }
109
+ /** Deep copy with sensitive keys and values masked. Bounded in depth, breadth and string length. */
110
+ value(input, depth = 0, seen = new WeakSet()) {
111
+ if (input === null || input === undefined)
112
+ return input;
113
+ if (typeof input === "string")
114
+ return this.string(input);
115
+ if (typeof input === "number" || typeof input === "boolean")
116
+ return input;
117
+ if (typeof input === "bigint")
118
+ return input.toString();
119
+ if (typeof input === "function" || typeof input === "symbol")
120
+ return undefined;
121
+ if (input instanceof Date)
122
+ return Number.isNaN(input.getTime()) ? null : input.toISOString();
123
+ if (Buffer.isBuffer(input) || ArrayBuffer.isView(input)) {
124
+ return `[Binary ${input.byteLength} bytes]`;
125
+ }
126
+ if (typeof input !== "object")
127
+ return undefined;
128
+ if (seen.has(input))
129
+ return "[Circular]";
130
+ if (depth >= 8)
131
+ return "[Truncated]";
132
+ seen.add(input);
133
+ if (Array.isArray(input)) {
134
+ const items = input.slice(0, 100).map((item) => this.value(item, depth + 1, seen));
135
+ if (input.length > 100)
136
+ items.push(`[${input.length - 100} more]`);
137
+ return items;
138
+ }
139
+ const out = {};
140
+ let count = 0;
141
+ for (const [key, nested] of Object.entries(input)) {
142
+ if (count++ >= 200) {
143
+ out["[truncated]"] = "too many keys";
144
+ break;
145
+ }
146
+ out[key] = this.isSensitiveKey(key) ? exports.REDACTED : this.value(nested, depth + 1, seen);
147
+ }
148
+ return out;
149
+ }
150
+ headers(headers) {
151
+ if (!headers)
152
+ return undefined;
153
+ const out = {};
154
+ for (const [rawName, rawValue] of Object.entries(headers)) {
155
+ if (rawValue === undefined)
156
+ continue;
157
+ const name = rawName.toLowerCase();
158
+ if (this.headerNames.has(name) || this.isSensitiveKey(name)) {
159
+ out[name] = exports.REDACTED;
160
+ continue;
161
+ }
162
+ const joined = Array.isArray(rawValue) ? rawValue.join(", ") : String(rawValue);
163
+ out[name] = this.string(joined, 1024);
164
+ }
165
+ return out;
166
+ }
167
+ query(search) {
168
+ if (!search)
169
+ return undefined;
170
+ const out = {};
171
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
172
+ for (const key of new Set(params.keys())) {
173
+ const values = params.getAll(key).map((value) => this.isSensitiveKey(key) ? exports.REDACTED : this.string(value, 512));
174
+ out[key] = values.length === 1 ? values[0] : values;
175
+ }
176
+ return out;
177
+ }
178
+ /**
179
+ * Redacts a captured body. Structured content is parsed and redacted by key;
180
+ * text that cannot be parsed (usually because it was truncated) gets key/value
181
+ * pattern masking instead, so a cut-off JSON body still loses its passwords.
182
+ */
183
+ body(raw, contentType, maxBytes) {
184
+ if (raw === undefined || raw === null || maxBytes <= 0)
185
+ return {};
186
+ const type = (contentType || "").toLowerCase();
187
+ if (typeof raw === "object" && !Buffer.isBuffer(raw)) {
188
+ return this.fit(this.value(raw), maxBytes);
189
+ }
190
+ const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
191
+ if (type.includes("json")) {
192
+ try {
193
+ return this.fit(this.value(JSON.parse(text)), maxBytes);
194
+ }
195
+ catch {
196
+ return this.cut(this.string(text, text.length), maxBytes);
197
+ }
198
+ }
199
+ if (type.includes("application/x-www-form-urlencoded")) {
200
+ return this.fit(this.query(text) ?? {}, maxBytes);
201
+ }
202
+ if (!type || type.startsWith("text/") || type.includes("xml") || type.includes("graphql")) {
203
+ return this.cut(this.string(text, maxBytes * 2), maxBytes);
204
+ }
205
+ return { omitted: `content-type ${type.split(";")[0]}` };
206
+ }
207
+ fit(value, maxBytes) {
208
+ const serialized = JSON.stringify(value) ?? "";
209
+ if (Buffer.byteLength(serialized) <= maxBytes) {
210
+ return { body: value };
211
+ }
212
+ return this.cut(serialized, maxBytes);
213
+ }
214
+ cut(text, maxBytes) {
215
+ const bytes = Buffer.from(text);
216
+ if (bytes.length <= maxBytes) {
217
+ return { body: text };
218
+ }
219
+ // Slicing bytes can split a multi-byte character; the replacement char is harmless here.
220
+ return { body: bytes.subarray(0, maxBytes).toString("utf8"), truncated: true };
221
+ }
222
+ }
223
+ exports.Redactor = Redactor;
package/dist/tap.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Keeps the first `limit` bytes of a stream that is being written elsewhere, and
3
+ * counts the rest. Copies what it keeps so a large chunk's backing buffer isn't
4
+ * retained just because its first few bytes were interesting.
5
+ */
6
+ export declare class BodyTap {
7
+ private readonly limit;
8
+ private readonly chunks;
9
+ private kept;
10
+ bytes: number;
11
+ constructor(limit: number);
12
+ push(chunk: unknown, encoding?: string): void;
13
+ get truncated(): boolean;
14
+ get body(): Buffer;
15
+ }
16
+ export declare function headerValue(value: unknown): string | undefined;
17
+ /** Compressed bytes are unreadable and not worth redacting; the body is skipped instead. */
18
+ export declare function isCompressed(contentEncoding: string | undefined): boolean;
package/dist/tap.js ADDED
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BodyTap = void 0;
4
+ exports.headerValue = headerValue;
5
+ exports.isCompressed = isCompressed;
6
+ /**
7
+ * Keeps the first `limit` bytes of a stream that is being written elsewhere, and
8
+ * counts the rest. Copies what it keeps so a large chunk's backing buffer isn't
9
+ * retained just because its first few bytes were interesting.
10
+ */
11
+ class BodyTap {
12
+ constructor(limit) {
13
+ this.limit = limit;
14
+ this.chunks = [];
15
+ this.kept = 0;
16
+ this.bytes = 0;
17
+ }
18
+ push(chunk, encoding) {
19
+ try {
20
+ if (chunk === undefined || chunk === null || typeof chunk === "function")
21
+ return;
22
+ let buffer;
23
+ if (Buffer.isBuffer(chunk)) {
24
+ buffer = chunk;
25
+ }
26
+ else if (typeof chunk === "string") {
27
+ buffer = Buffer.from(chunk, Buffer.isEncoding(encoding ?? "") ? encoding : "utf8");
28
+ }
29
+ else if (chunk instanceof Uint8Array) {
30
+ buffer = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
31
+ }
32
+ else {
33
+ return;
34
+ }
35
+ this.bytes += buffer.length;
36
+ if (this.kept < this.limit) {
37
+ const part = buffer.subarray(0, this.limit - this.kept);
38
+ this.chunks.push(Buffer.from(part));
39
+ this.kept += part.length;
40
+ }
41
+ }
42
+ catch {
43
+ // Observing a body must never break writing it.
44
+ }
45
+ }
46
+ get truncated() {
47
+ return this.bytes > this.kept;
48
+ }
49
+ get body() {
50
+ return Buffer.concat(this.chunks);
51
+ }
52
+ }
53
+ exports.BodyTap = BodyTap;
54
+ function headerValue(value) {
55
+ if (Array.isArray(value))
56
+ return value.length ? String(value[0]) : undefined;
57
+ return value === undefined || value === null ? undefined : String(value);
58
+ }
59
+ /** Compressed bytes are unreadable and not worth redacting; the body is skipped instead. */
60
+ function isCompressed(contentEncoding) {
61
+ return Boolean(contentEncoding && contentEncoding.toLowerCase() !== "identity");
62
+ }
@@ -0,0 +1,35 @@
1
+ import * as http from "http";
2
+ export interface PostResult {
3
+ status: number;
4
+ headers: http.IncomingHttpHeaders;
5
+ body: string;
6
+ }
7
+ export interface TransportOptions {
8
+ /** Full trust store for https, or undefined for Node's default. Verification is never disabled. */
9
+ ca?: Array<string | Buffer>;
10
+ connectTimeoutMs: number;
11
+ timeoutMs: number;
12
+ userAgent: string;
13
+ /** Response bodies are only read for diagnostics; anything past this is discarded. */
14
+ maxResponseBytes?: number;
15
+ }
16
+ /** A transport failure with a stable `code`, including the two timeouts Node doesn't name. */
17
+ export declare class TransportError extends Error {
18
+ readonly code: string;
19
+ constructor(message: string, code: string);
20
+ }
21
+ /**
22
+ * Minimal JSON POST over Node's own http/https.
23
+ *
24
+ * Node's modules rather than fetch because this needs things fetch doesn't expose
25
+ * portably: a per-endpoint CA that extends rather than replaces the trust store, a
26
+ * connect timeout separate from the request deadline, and sockets that don't keep
27
+ * the host process alive.
28
+ */
29
+ export declare class Transport {
30
+ private readonly options;
31
+ private readonly agent;
32
+ constructor(origin: URL, options: TransportOptions);
33
+ post(url: URL, body: string, headers: Record<string, string>, keepProcessAlive: boolean): Promise<PostResult>;
34
+ destroy(): void;
35
+ }
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.Transport = exports.TransportError = void 0;
37
+ const http = __importStar(require("http"));
38
+ const https = __importStar(require("https"));
39
+ /** A transport failure with a stable `code`, including the two timeouts Node doesn't name. */
40
+ class TransportError extends Error {
41
+ constructor(message, code) {
42
+ super(message);
43
+ this.code = code;
44
+ this.name = "TransportError";
45
+ }
46
+ }
47
+ exports.TransportError = TransportError;
48
+ /**
49
+ * Minimal JSON POST over Node's own http/https.
50
+ *
51
+ * Node's modules rather than fetch because this needs things fetch doesn't expose
52
+ * portably: a per-endpoint CA that extends rather than replaces the trust store, a
53
+ * connect timeout separate from the request deadline, and sockets that don't keep
54
+ * the host process alive.
55
+ */
56
+ class Transport {
57
+ constructor(origin, options) {
58
+ this.options = options;
59
+ this.agent = origin.protocol === "https:"
60
+ ? new https.Agent({ keepAlive: true, maxSockets: 4, ca: options.ca })
61
+ : new http.Agent({ keepAlive: true, maxSockets: 4 });
62
+ }
63
+ post(url, body, headers, keepProcessAlive) {
64
+ const { connectTimeoutMs, timeoutMs } = this.options;
65
+ const maxResponseBytes = this.options.maxResponseBytes ?? 64 * 1024;
66
+ const isHttps = url.protocol === "https:";
67
+ return new Promise((resolve, reject) => {
68
+ let settled = false;
69
+ let connectTimer;
70
+ const settle = (fn) => {
71
+ if (settled)
72
+ return;
73
+ settled = true;
74
+ clearTimeout(deadline);
75
+ if (connectTimer)
76
+ clearTimeout(connectTimer);
77
+ fn();
78
+ };
79
+ const request = (isHttps ? https : http).request(url, {
80
+ method: "POST",
81
+ agent: this.agent,
82
+ headers: {
83
+ ...headers,
84
+ "content-type": "application/json",
85
+ "content-length": String(Buffer.byteLength(body)),
86
+ "user-agent": this.options.userAgent,
87
+ },
88
+ });
89
+ const deadline = setTimeout(() => {
90
+ request.destroy(new TransportError(`no response within ${timeoutMs}ms`, "ETIMEDOUT"));
91
+ }, timeoutMs);
92
+ request.on("socket", (socket) => {
93
+ if (!keepProcessAlive) {
94
+ socket.unref();
95
+ }
96
+ else {
97
+ socket.ref();
98
+ }
99
+ // A pooled keep-alive socket is already connected; only time fresh ones.
100
+ // The overall deadline still covers a handshake that stalls after connect.
101
+ if (socket.connecting) {
102
+ connectTimer = setTimeout(() => {
103
+ request.destroy(new TransportError(`connection not established within ${connectTimeoutMs}ms`, "ECONNECT_TIMEOUT"));
104
+ }, connectTimeoutMs);
105
+ socket.once(isHttps ? "secureConnect" : "connect", () => clearTimeout(connectTimer));
106
+ }
107
+ });
108
+ request.on("response", (response) => {
109
+ const chunks = [];
110
+ let received = 0;
111
+ response.on("data", (chunk) => {
112
+ if (received < maxResponseBytes) {
113
+ chunks.push(chunk.subarray(0, maxResponseBytes - received));
114
+ }
115
+ received += chunk.length;
116
+ });
117
+ response.on("end", () => settle(() => resolve({
118
+ status: response.statusCode ?? 0,
119
+ headers: response.headers,
120
+ body: Buffer.concat(chunks).toString("utf8"),
121
+ })));
122
+ response.on("error", (err) => settle(() => reject(err)));
123
+ response.on("aborted", () => settle(() => reject(new TransportError("response aborted by the server", "ECONNRESET"))));
124
+ });
125
+ request.on("error", (err) => settle(() => reject(err)));
126
+ request.end(body);
127
+ });
128
+ }
129
+ destroy() {
130
+ this.agent.destroy();
131
+ }
132
+ }
133
+ exports.Transport = Transport;
package/dist/types.d.ts CHANGED
@@ -1,22 +1,103 @@
1
+ /** A PEM string, PEM bytes, a path to a PEM file, or several of those. */
2
+ export type CaInput = string | Buffer | Array<string | Buffer>;
3
+ export type EventType = "request" | "error" | "security" | "performance" | "custom";
4
+ export type EventSeverity = "low" | "medium" | "high" | "critical";
5
+ export type EventCategory = "application" | "infrastructure" | "security" | "performance" | "business";
6
+ /**
7
+ * What gets copied off a request/response in addition to method, path, status and
8
+ * timing. Everything here is off by default: headers, query strings and bodies are
9
+ * where credentials and personal data live, so capturing them is an explicit
10
+ * decision. Whatever is captured is redacted in this process before it is queued.
11
+ */
12
+ export interface CaptureOptions {
13
+ /** Request and response headers. Default false. */
14
+ headers?: boolean;
15
+ /** Query-string parameters. Default false. */
16
+ query?: boolean;
17
+ /** Request body — the parsed `req.body` in middleware mode, the raw stream in proxy mode. Default false. */
18
+ requestBody?: boolean;
19
+ /** Response body. Default false. */
20
+ responseBody?: boolean;
21
+ /** Per-body cap in bytes; anything longer is cut and marked `truncated`. Default 4096. */
22
+ maxBodyBytes?: number;
23
+ }
1
24
  export interface MidlineConfig {
2
- apiKey: string;
3
- serviceName: string;
4
- maskFields?: string[];
25
+ /** Project API key from the Midline dashboard. Env fallback: `MIDLINE_API_KEY`. */
26
+ apiKey?: string;
27
+ /** Name of the service reporting. Env fallback: `MIDLINE_SERVICE_NAME`. */
28
+ serviceName?: string;
29
+ /**
30
+ * The Midline server — the control plane that receives events. Accepts a base URL
31
+ * (`https://api.usemidline.com`) or the full ingest URL. `https://` is required;
32
+ * plain `http://` is only accepted for loopback hosts, so the API key never
33
+ * crosses a network in cleartext. Env fallback: `MIDLINE_ENDPOINT`.
34
+ *
35
+ * This is never the API being monitored. See `createMidlineProxy({ target })`.
36
+ */
5
37
  endpoint?: string;
38
+ /**
39
+ * Extra certificate authorities to trust for `endpoint` only — for a self-hosted
40
+ * Midline server behind a private CA. Added to Node's default trust store, never a
41
+ * replacement for it, and never a way to switch verification off. Env fallback:
42
+ * `MIDLINE_CUSTOM_CA` (PEM text or a path to a PEM file).
43
+ */
44
+ ca?: CaInput;
6
45
  environment?: string;
7
46
  host?: string;
8
47
  region?: string;
9
48
  /** Release/version tag — shows up on every event so Midline can tell you which deploy introduced or reintroduced an issue. */
10
49
  release?: string;
50
+ /** Extra field names to redact, on top of the built-in list. Matched case- and punctuation-insensitively. */
51
+ redactFields?: string[];
52
+ /** @deprecated Use `redactFields`. Still honoured. */
53
+ maskFields?: string[];
54
+ /** Extra header names to redact, on top of the built-in list. */
55
+ redactHeaders?: string[];
56
+ /** What to capture beyond method/path/status/timing. Defaults to nothing. */
57
+ capture?: CaptureOptions;
58
+ /** Set to false to keep the agent inert. Env fallback: `MIDLINE_ENABLED`. */
59
+ enabled?: boolean;
60
+ /**
61
+ * Receives the agent's own diagnostics so they can go to your logger instead of
62
+ * the console. Set `debug` too if you want them in both places.
63
+ */
64
+ onError?: (message: string) => void;
65
+ /** Emit every diagnostic instead of one per distinct fault. Env fallback: `MIDLINE_DEBUG`. */
66
+ debug?: boolean;
67
+ /** How often the queue is flushed, in ms. Default 1500. */
68
+ flushIntervalMs?: number;
69
+ /** Deadline for a whole delivery request, in ms. Default 10000. */
70
+ timeoutMs?: number;
71
+ /** Deadline for the TCP + TLS handshake, in ms. Default 5000. */
72
+ connectTimeoutMs?: number;
73
+ /** Events per delivery request. Default 100, max 500. */
74
+ maxBatchSize?: number;
75
+ /** Events buffered while the Midline server is unreachable. Oldest are dropped past this. Default 1000. */
76
+ maxQueueSize?: number;
77
+ /** Serialised size cap for a single event; larger events lose their captured bodies first. Default and maximum 65536. */
78
+ maxEventBytes?: number;
79
+ /** Upper bound on retry backoff, in ms. Default 300000. */
80
+ maxRetryDelayMs?: number;
11
81
  }
12
82
  export interface Breadcrumb {
13
83
  type: string;
14
84
  message: string;
15
85
  timestamp: string;
16
86
  }
87
+ export interface CapturedMessage {
88
+ headers?: Record<string, string>;
89
+ query?: Record<string, string | string[]>;
90
+ body?: unknown;
91
+ /** Bytes seen on the wire (or serialised), before truncation. */
92
+ bodyBytes?: number;
93
+ truncated?: boolean;
94
+ /** Why a body was not captured, e.g. a binary content type. */
95
+ omitted?: string;
96
+ }
17
97
  export interface MidlineEvent {
18
- type: "request" | "error";
19
- timestamp: string;
98
+ type: EventType;
99
+ /** ISO timestamp. Defaults to the moment the event is added. */
100
+ timestamp?: string;
20
101
  path: string;
21
102
  method?: string;
22
103
  statusCode?: number;
@@ -25,6 +106,27 @@ export interface MidlineEvent {
25
106
  stack?: string;
26
107
  ip?: string;
27
108
  userAgent?: string;
109
+ severity?: EventSeverity;
110
+ category?: EventCategory;
111
+ ruleId?: string;
112
+ threatDetected?: boolean;
28
113
  /** Recent requests leading up to this event — only populated on errors. */
29
114
  breadcrumbs?: Breadcrumb[];
115
+ requestId?: string;
116
+ correlationId?: string;
117
+ traceId?: string;
118
+ spanId?: string;
119
+ /** Route pattern when the framework exposes one, e.g. `/users/:id`. */
120
+ routeTemplate?: string;
121
+ request?: CapturedMessage;
122
+ response?: CapturedMessage;
123
+ /** The API a proxied request was forwarded to. Only set in proxy mode. */
124
+ destination?: {
125
+ url: string;
126
+ };
127
+ /** Machine-readable failure code, e.g. `ECONNREFUSED` for an unreachable destination. */
128
+ errorCode?: string;
129
+ /** Set when the client disconnected before the response finished. */
130
+ aborted?: boolean;
131
+ integration?: "express" | "node-http" | "proxy" | "manual";
30
132
  }
package/package.json CHANGED
@@ -1,14 +1,20 @@
1
1
  {
2
2
  "name": "midline-agent",
3
- "version": "0.1.9",
4
- "description": "Express.js SDK for Midline — request & error monitoring with security detection",
3
+ "version": "0.2.0",
4
+ "description": "Midline — request & error monitoring with security detection",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
+ "bin": {
8
+ "midline-agent": "dist/cli.js"
9
+ },
10
+ "engines": {
11
+ "node": ">=18"
12
+ },
7
13
  "scripts": {
8
14
  "build": "tsc",
9
15
  "prepare": "npm run build",
10
16
  "clean": "rm -rf dist",
11
- "start": "node dist/example.js"
17
+ "test": "npm run build && node --test test/*.test.js"
12
18
  },
13
19
  "keywords": [
14
20
  "midline",
@@ -17,16 +23,15 @@
17
23
  "sdk",
18
24
  "security",
19
25
  "request",
20
- "errors"
26
+ "errors",
27
+ "proxy"
21
28
  ],
22
29
  "author": "Your Name",
23
30
  "license": "MIT",
24
- "dependencies": {
25
- "cross-fetch": "^4.1.0",
26
- "express": "^4.18.2"
27
- },
28
31
  "devDependencies": {
29
32
  "@types/express": "^4.17.19",
33
+ "@types/node": "^24.10.1",
34
+ "express": "^4.18.2",
30
35
  "typescript": "^5.1.3"
31
36
  },
32
37
  "publishConfig": {