@usebetterdev/audit-express 0.5.0-beta.1
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 +141 -0
- package/dist/index.cjs +86 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +46 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# @usebetterdev/audit-express
|
|
2
|
+
|
|
3
|
+
Express middleware for [`@usebetterdev/audit-core`](../core). Automatically extracts actor identity from incoming requests and makes it available to audit logging via `AsyncLocalStorage`.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @usebetterdev/audit-express @usebetterdev/audit-core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import express from "express";
|
|
15
|
+
import { betterAudit } from "@usebetterdev/audit-core";
|
|
16
|
+
import { betterAuditExpress } from "@usebetterdev/audit-express";
|
|
17
|
+
|
|
18
|
+
const audit = betterAudit({
|
|
19
|
+
database: { writeLog: async (log) => console.log(log) },
|
|
20
|
+
auditTables: ["users", "orders"],
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const app = express();
|
|
24
|
+
app.use(express.json());
|
|
25
|
+
|
|
26
|
+
// Use the middleware — by default it reads the `sub` claim
|
|
27
|
+
// from the Authorization: Bearer <jwt> header.
|
|
28
|
+
app.use(betterAuditExpress());
|
|
29
|
+
|
|
30
|
+
app.post("/users", async (req, res, next) => {
|
|
31
|
+
try {
|
|
32
|
+
// actorId is automatically attached from the JWT
|
|
33
|
+
await audit.captureLog({
|
|
34
|
+
tableName: "users",
|
|
35
|
+
operation: "INSERT",
|
|
36
|
+
recordId: "user-42",
|
|
37
|
+
after: { name: "Alice" },
|
|
38
|
+
});
|
|
39
|
+
res.status(201).json({ id: "user-42" });
|
|
40
|
+
} catch (error) {
|
|
41
|
+
next(error);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Actor extraction
|
|
47
|
+
|
|
48
|
+
The middleware resolves the current actor (the user or service making the request) and stores it in `AuditContext.actorId`. Every log captured during that request automatically includes the actor.
|
|
49
|
+
|
|
50
|
+
### Default: JWT Bearer token
|
|
51
|
+
|
|
52
|
+
With no options, the middleware decodes the `sub` claim from the `Authorization: Bearer <jwt>` header. The token is decoded **without** signature verification — that is the auth layer's responsibility.
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
app.use(betterAuditExpress());
|
|
56
|
+
// Authorization: Bearer eyJ... → actorId = jwt.sub
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Custom JWT claim
|
|
60
|
+
|
|
61
|
+
Extract a different claim by providing a custom extractor:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { fromBearerToken } from "@usebetterdev/audit-core";
|
|
65
|
+
|
|
66
|
+
app.use(betterAuditExpress({ extractor: { actor: fromBearerToken("user_id") } }));
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Header-based extraction
|
|
70
|
+
|
|
71
|
+
Use `fromHeader` when the actor identity is passed as a plain request header (common behind API gateways):
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { fromHeader } from "@usebetterdev/audit-core";
|
|
75
|
+
|
|
76
|
+
app.use(betterAuditExpress({ extractor: { actor: fromHeader("x-user-id") } }));
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Cookie-based extraction
|
|
80
|
+
|
|
81
|
+
Use `fromCookie` for session-based auth where the actor ID lives in a cookie:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { fromCookie } from "@usebetterdev/audit-core";
|
|
85
|
+
|
|
86
|
+
app.use(betterAuditExpress({ extractor: { actor: fromCookie("session_id") } }));
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Custom extractor function
|
|
90
|
+
|
|
91
|
+
Write your own `ValueExtractor` for full control. It receives a Web-standard `Request` and returns a string or `undefined`:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
app.use(
|
|
95
|
+
betterAuditExpress({
|
|
96
|
+
extractor: {
|
|
97
|
+
actor: async (request) => {
|
|
98
|
+
const apiKey = request.headers.get("x-api-key");
|
|
99
|
+
if (!apiKey) return undefined;
|
|
100
|
+
const owner = await resolveApiKeyOwner(apiKey);
|
|
101
|
+
return owner?.id;
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## ALS scope and async handlers
|
|
109
|
+
|
|
110
|
+
The middleware keeps the `AsyncLocalStorage` scope open until the response finishes (via `response.on('finish'/'close')`). This means audit context is available even after `await` inside async route handlers — the context does not reset at the first `await` boundary.
|
|
111
|
+
|
|
112
|
+
## Error handling
|
|
113
|
+
|
|
114
|
+
Extraction errors never break the request. By default the middleware **fails open** — if an extractor throws, the request proceeds without audit context.
|
|
115
|
+
|
|
116
|
+
Use `onError` to log or report extraction failures:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
app.use(
|
|
120
|
+
betterAuditExpress({
|
|
121
|
+
onError: (error) => console.error("Audit extraction failed:", error),
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## API
|
|
127
|
+
|
|
128
|
+
### `betterAuditExpress(options?)`
|
|
129
|
+
|
|
130
|
+
Convenience wrapper that returns an Express middleware. Equivalent to `createExpressMiddleware`.
|
|
131
|
+
|
|
132
|
+
### `createExpressMiddleware(options?)`
|
|
133
|
+
|
|
134
|
+
Creates an Express-compatible middleware function `(req, res, next) => Promise<void>`.
|
|
135
|
+
|
|
136
|
+
**Options:**
|
|
137
|
+
|
|
138
|
+
| Option | Type | Description |
|
|
139
|
+
| ----------- | -------------------------- | ---------------------------------------------------- |
|
|
140
|
+
| `extractor` | `ContextExtractor` | Actor extractor config. Defaults to JWT `sub` claim. |
|
|
141
|
+
| `onError` | `(error: unknown) => void` | Called when an extractor throws. Defaults to no-op. |
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
betterAuditExpress: () => betterAuditExpress,
|
|
24
|
+
createExpressMiddleware: () => createExpressMiddleware
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(index_exports);
|
|
27
|
+
var import_audit_core = require("@usebetterdev/audit-core");
|
|
28
|
+
function toWebRequest(req) {
|
|
29
|
+
const headers = new Headers();
|
|
30
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
31
|
+
if (value === void 0) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (Array.isArray(value)) {
|
|
35
|
+
headers.set(key, value.join(", "));
|
|
36
|
+
} else if (typeof value === "number") {
|
|
37
|
+
headers.set(key, String(value));
|
|
38
|
+
} else {
|
|
39
|
+
headers.set(key, value);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const rawPath = req.originalUrl ?? req.url;
|
|
43
|
+
const url = rawPath.startsWith("/") ? `http://${req.hostname ?? "localhost"}${rawPath}` : rawPath;
|
|
44
|
+
return new Request(url, {
|
|
45
|
+
method: req.method ?? "GET",
|
|
46
|
+
headers
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
var defaultExtractor = {
|
|
50
|
+
actor: (0, import_audit_core.fromBearerToken)("sub")
|
|
51
|
+
};
|
|
52
|
+
function createExpressMiddleware(options = {}) {
|
|
53
|
+
const extractor = options.extractor ?? defaultExtractor;
|
|
54
|
+
const handlerOptions = {};
|
|
55
|
+
if (options.onError) {
|
|
56
|
+
handlerOptions.onError = options.onError;
|
|
57
|
+
}
|
|
58
|
+
return async (request, response, next) => {
|
|
59
|
+
try {
|
|
60
|
+
const webRequest = toWebRequest(request);
|
|
61
|
+
const nextWrapper = () => new Promise((resolve) => {
|
|
62
|
+
const done = () => resolve();
|
|
63
|
+
if (response.on) {
|
|
64
|
+
response.on("finish", done);
|
|
65
|
+
response.on("close", done);
|
|
66
|
+
}
|
|
67
|
+
next();
|
|
68
|
+
if (!response.on) {
|
|
69
|
+
done();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
await (0, import_audit_core.handleMiddleware)(extractor, webRequest, nextWrapper, handlerOptions);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
next(error);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function betterAuditExpress(options = {}) {
|
|
79
|
+
return createExpressMiddleware(options);
|
|
80
|
+
}
|
|
81
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
82
|
+
0 && (module.exports = {
|
|
83
|
+
betterAuditExpress,
|
|
84
|
+
createExpressMiddleware
|
|
85
|
+
});
|
|
86
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type {\n AuditContext,\n ContextExtractor,\n MiddlewareHandlerOptions,\n} from \"@usebetterdev/audit-core\";\nimport {\n handleMiddleware,\n fromBearerToken,\n} from \"@usebetterdev/audit-core\";\n\n// ---------------------------------------------------------------------------\n// Interface types\n// ---------------------------------------------------------------------------\n\nexport interface ExpressRequestLike {\n headers: Record<string, string | string[] | number | undefined>;\n url: string;\n originalUrl?: string;\n hostname?: string;\n method?: string;\n}\n\nexport interface ExpressResponseLike {\n /** Used to detect when the response is finished so the ALS scope stays open. Real Express responses always have this (inherited from http.ServerResponse). */\n on?: (event: string, listener: (...args: unknown[]) => void) => unknown;\n}\n\nexport type ExpressNextFunction = (error?: unknown) => void;\n\nexport type ExpressMiddleware = (\n request: ExpressRequestLike,\n response: ExpressResponseLike,\n next: ExpressNextFunction,\n) => Promise<void>;\n\nexport interface CreateExpressMiddlewareOptions {\n /** Context extractor for pulling actor identity from the request. */\n extractor?: ContextExtractor;\n /** Called when an extractor throws. Defaults to silent fail-open. */\n onError?: (error: unknown) => void;\n}\n\n// ---------------------------------------------------------------------------\n// Request bridging\n// ---------------------------------------------------------------------------\n\n/**\n * Bridge an Express `IncomingMessage`-shaped request to a Web `Request`.\n *\n * Node.js headers can be `string | string[] | number | undefined`.\n * This normalises them to the `string` values `Headers` expects:\n * - `string[]` → joined with `\", \"`\n * - `number` → converted with `String()`\n * - `undefined`→ skipped\n *\n * **Security note:** `req.hostname` comes from the `Host` header and is\n * unvalidated user input. The reconstructed URL is used only to satisfy the\n * Web `Request` constructor — built-in extractors read headers, not the URL\n * authority. Custom extractors must not trust `new URL(request.url).hostname`.\n */\nfunction toWebRequest(req: ExpressRequestLike): Request {\n const headers = new Headers();\n for (const [key, value] of Object.entries(req.headers)) {\n if (value === undefined) {\n continue;\n }\n if (Array.isArray(value)) {\n headers.set(key, value.join(\", \"));\n } else if (typeof value === \"number\") {\n headers.set(key, String(value));\n } else {\n headers.set(key, value);\n }\n }\n\n const rawPath = req.originalUrl ?? req.url;\n const url = rawPath.startsWith(\"/\")\n ? `http://${req.hostname ?? \"localhost\"}${rawPath}`\n : rawPath;\n\n return new Request(url, {\n method: req.method ?? \"GET\",\n headers,\n });\n}\n\n// ---------------------------------------------------------------------------\n// Default extractor\n// ---------------------------------------------------------------------------\n\n/** Default extractor: reads `sub` from `Authorization: Bearer <jwt>` as actor. */\nconst defaultExtractor: ContextExtractor = {\n actor: fromBearerToken(\"sub\"),\n};\n\n// ---------------------------------------------------------------------------\n// Middleware\n// ---------------------------------------------------------------------------\n\n/**\n * Creates an Express-compatible middleware that populates audit context\n * via AsyncLocalStorage on every request.\n *\n * Bridges Express's `IncomingMessage` to a Web `Request`, then delegates\n * to the shared `handleMiddleware()` from audit-core.\n *\n * **ALS scope lifetime:** the scope stays open until the response finishes\n * (via `response.on('finish'/'close')`), so audit context is available\n * even inside `await`-ed operations in async route handlers.\n *\n * The middleware is non-blocking: if context extraction fails, the request\n * proceeds without audit context (fail open).\n */\nexport function createExpressMiddleware(\n options: CreateExpressMiddlewareOptions = {},\n): ExpressMiddleware {\n const extractor = options.extractor ?? defaultExtractor;\n const handlerOptions: MiddlewareHandlerOptions = {};\n if (options.onError) {\n handlerOptions.onError = options.onError;\n }\n\n return async (request, response, next) => {\n try {\n const webRequest = toWebRequest(request);\n\n const nextWrapper = (): Promise<void> =>\n new Promise<void>((resolve) => {\n const done = () => resolve();\n if (response.on) {\n response.on(\"finish\", done);\n response.on(\"close\", done);\n }\n next();\n if (!response.on) {\n done();\n }\n });\n\n await handleMiddleware(extractor, webRequest, nextWrapper, handlerOptions);\n } catch (error) {\n next(error);\n }\n };\n}\n\n/**\n * Convenience wrapper: `app.use(betterAuditExpress())`.\n *\n * Uses the default JWT extractor (reads `sub` from `Authorization: Bearer <jwt>`).\n * Pass options to customise extraction.\n */\nexport function betterAuditExpress(\n options: CreateExpressMiddlewareOptions = {},\n): ExpressMiddleware {\n return createExpressMiddleware(options);\n}\n\nexport type { AuditContext, ContextExtractor };\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAKA,wBAGO;AAoDP,SAAS,aAAa,KAAkC;AACtD,QAAM,UAAU,IAAI,QAAQ;AAC5B,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,IAAI,OAAO,GAAG;AACtD,QAAI,UAAU,QAAW;AACvB;AAAA,IACF;AACA,QAAI,MAAM,QAAQ,KAAK,GAAG;AACxB,cAAQ,IAAI,KAAK,MAAM,KAAK,IAAI,CAAC;AAAA,IACnC,WAAW,OAAO,UAAU,UAAU;AACpC,cAAQ,IAAI,KAAK,OAAO,KAAK,CAAC;AAAA,IAChC,OAAO;AACL,cAAQ,IAAI,KAAK,KAAK;AAAA,IACxB;AAAA,EACF;AAEA,QAAM,UAAU,IAAI,eAAe,IAAI;AACvC,QAAM,MAAM,QAAQ,WAAW,GAAG,IAC9B,UAAU,IAAI,YAAY,WAAW,GAAG,OAAO,KAC/C;AAEJ,SAAO,IAAI,QAAQ,KAAK;AAAA,IACtB,QAAQ,IAAI,UAAU;AAAA,IACtB;AAAA,EACF,CAAC;AACH;AAOA,IAAM,mBAAqC;AAAA,EACzC,WAAO,mCAAgB,KAAK;AAC9B;AAoBO,SAAS,wBACd,UAA0C,CAAC,GACxB;AACnB,QAAM,YAAY,QAAQ,aAAa;AACvC,QAAM,iBAA2C,CAAC;AAClD,MAAI,QAAQ,SAAS;AACnB,mBAAe,UAAU,QAAQ;AAAA,EACnC;AAEA,SAAO,OAAO,SAAS,UAAU,SAAS;AACxC,QAAI;AACF,YAAM,aAAa,aAAa,OAAO;AAEvC,YAAM,cAAc,MAClB,IAAI,QAAc,CAAC,YAAY;AAC7B,cAAM,OAAO,MAAM,QAAQ;AAC3B,YAAI,SAAS,IAAI;AACf,mBAAS,GAAG,UAAU,IAAI;AAC1B,mBAAS,GAAG,SAAS,IAAI;AAAA,QAC3B;AACA,aAAK;AACL,YAAI,CAAC,SAAS,IAAI;AAChB,eAAK;AAAA,QACP;AAAA,MACF,CAAC;AAEH,gBAAM,oCAAiB,WAAW,YAAY,aAAa,cAAc;AAAA,IAC3E,SAAS,OAAO;AACd,WAAK,KAAK;AAAA,IACZ;AAAA,EACF;AACF;AAQO,SAAS,mBACd,UAA0C,CAAC,GACxB;AACnB,SAAO,wBAAwB,OAAO;AACxC;","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ContextExtractor } from '@usebetterdev/audit-core';
|
|
2
|
+
export { AuditContext, ContextExtractor } from '@usebetterdev/audit-core';
|
|
3
|
+
|
|
4
|
+
interface ExpressRequestLike {
|
|
5
|
+
headers: Record<string, string | string[] | number | undefined>;
|
|
6
|
+
url: string;
|
|
7
|
+
originalUrl?: string;
|
|
8
|
+
hostname?: string;
|
|
9
|
+
method?: string;
|
|
10
|
+
}
|
|
11
|
+
interface ExpressResponseLike {
|
|
12
|
+
/** Used to detect when the response is finished so the ALS scope stays open. Real Express responses always have this (inherited from http.ServerResponse). */
|
|
13
|
+
on?: (event: string, listener: (...args: unknown[]) => void) => unknown;
|
|
14
|
+
}
|
|
15
|
+
type ExpressNextFunction = (error?: unknown) => void;
|
|
16
|
+
type ExpressMiddleware = (request: ExpressRequestLike, response: ExpressResponseLike, next: ExpressNextFunction) => Promise<void>;
|
|
17
|
+
interface CreateExpressMiddlewareOptions {
|
|
18
|
+
/** Context extractor for pulling actor identity from the request. */
|
|
19
|
+
extractor?: ContextExtractor;
|
|
20
|
+
/** Called when an extractor throws. Defaults to silent fail-open. */
|
|
21
|
+
onError?: (error: unknown) => void;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Creates an Express-compatible middleware that populates audit context
|
|
25
|
+
* via AsyncLocalStorage on every request.
|
|
26
|
+
*
|
|
27
|
+
* Bridges Express's `IncomingMessage` to a Web `Request`, then delegates
|
|
28
|
+
* to the shared `handleMiddleware()` from audit-core.
|
|
29
|
+
*
|
|
30
|
+
* **ALS scope lifetime:** the scope stays open until the response finishes
|
|
31
|
+
* (via `response.on('finish'/'close')`), so audit context is available
|
|
32
|
+
* even inside `await`-ed operations in async route handlers.
|
|
33
|
+
*
|
|
34
|
+
* The middleware is non-blocking: if context extraction fails, the request
|
|
35
|
+
* proceeds without audit context (fail open).
|
|
36
|
+
*/
|
|
37
|
+
declare function createExpressMiddleware(options?: CreateExpressMiddlewareOptions): ExpressMiddleware;
|
|
38
|
+
/**
|
|
39
|
+
* Convenience wrapper: `app.use(betterAuditExpress())`.
|
|
40
|
+
*
|
|
41
|
+
* Uses the default JWT extractor (reads `sub` from `Authorization: Bearer <jwt>`).
|
|
42
|
+
* Pass options to customise extraction.
|
|
43
|
+
*/
|
|
44
|
+
declare function betterAuditExpress(options?: CreateExpressMiddlewareOptions): ExpressMiddleware;
|
|
45
|
+
|
|
46
|
+
export { type CreateExpressMiddlewareOptions, type ExpressMiddleware, type ExpressNextFunction, type ExpressRequestLike, type ExpressResponseLike, betterAuditExpress, createExpressMiddleware };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ContextExtractor } from '@usebetterdev/audit-core';
|
|
2
|
+
export { AuditContext, ContextExtractor } from '@usebetterdev/audit-core';
|
|
3
|
+
|
|
4
|
+
interface ExpressRequestLike {
|
|
5
|
+
headers: Record<string, string | string[] | number | undefined>;
|
|
6
|
+
url: string;
|
|
7
|
+
originalUrl?: string;
|
|
8
|
+
hostname?: string;
|
|
9
|
+
method?: string;
|
|
10
|
+
}
|
|
11
|
+
interface ExpressResponseLike {
|
|
12
|
+
/** Used to detect when the response is finished so the ALS scope stays open. Real Express responses always have this (inherited from http.ServerResponse). */
|
|
13
|
+
on?: (event: string, listener: (...args: unknown[]) => void) => unknown;
|
|
14
|
+
}
|
|
15
|
+
type ExpressNextFunction = (error?: unknown) => void;
|
|
16
|
+
type ExpressMiddleware = (request: ExpressRequestLike, response: ExpressResponseLike, next: ExpressNextFunction) => Promise<void>;
|
|
17
|
+
interface CreateExpressMiddlewareOptions {
|
|
18
|
+
/** Context extractor for pulling actor identity from the request. */
|
|
19
|
+
extractor?: ContextExtractor;
|
|
20
|
+
/** Called when an extractor throws. Defaults to silent fail-open. */
|
|
21
|
+
onError?: (error: unknown) => void;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Creates an Express-compatible middleware that populates audit context
|
|
25
|
+
* via AsyncLocalStorage on every request.
|
|
26
|
+
*
|
|
27
|
+
* Bridges Express's `IncomingMessage` to a Web `Request`, then delegates
|
|
28
|
+
* to the shared `handleMiddleware()` from audit-core.
|
|
29
|
+
*
|
|
30
|
+
* **ALS scope lifetime:** the scope stays open until the response finishes
|
|
31
|
+
* (via `response.on('finish'/'close')`), so audit context is available
|
|
32
|
+
* even inside `await`-ed operations in async route handlers.
|
|
33
|
+
*
|
|
34
|
+
* The middleware is non-blocking: if context extraction fails, the request
|
|
35
|
+
* proceeds without audit context (fail open).
|
|
36
|
+
*/
|
|
37
|
+
declare function createExpressMiddleware(options?: CreateExpressMiddlewareOptions): ExpressMiddleware;
|
|
38
|
+
/**
|
|
39
|
+
* Convenience wrapper: `app.use(betterAuditExpress())`.
|
|
40
|
+
*
|
|
41
|
+
* Uses the default JWT extractor (reads `sub` from `Authorization: Bearer <jwt>`).
|
|
42
|
+
* Pass options to customise extraction.
|
|
43
|
+
*/
|
|
44
|
+
declare function betterAuditExpress(options?: CreateExpressMiddlewareOptions): ExpressMiddleware;
|
|
45
|
+
|
|
46
|
+
export { type CreateExpressMiddlewareOptions, type ExpressMiddleware, type ExpressNextFunction, type ExpressRequestLike, type ExpressResponseLike, betterAuditExpress, createExpressMiddleware };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import {
|
|
3
|
+
handleMiddleware,
|
|
4
|
+
fromBearerToken
|
|
5
|
+
} from "@usebetterdev/audit-core";
|
|
6
|
+
function toWebRequest(req) {
|
|
7
|
+
const headers = new Headers();
|
|
8
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
9
|
+
if (value === void 0) {
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
if (Array.isArray(value)) {
|
|
13
|
+
headers.set(key, value.join(", "));
|
|
14
|
+
} else if (typeof value === "number") {
|
|
15
|
+
headers.set(key, String(value));
|
|
16
|
+
} else {
|
|
17
|
+
headers.set(key, value);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const rawPath = req.originalUrl ?? req.url;
|
|
21
|
+
const url = rawPath.startsWith("/") ? `http://${req.hostname ?? "localhost"}${rawPath}` : rawPath;
|
|
22
|
+
return new Request(url, {
|
|
23
|
+
method: req.method ?? "GET",
|
|
24
|
+
headers
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
var defaultExtractor = {
|
|
28
|
+
actor: fromBearerToken("sub")
|
|
29
|
+
};
|
|
30
|
+
function createExpressMiddleware(options = {}) {
|
|
31
|
+
const extractor = options.extractor ?? defaultExtractor;
|
|
32
|
+
const handlerOptions = {};
|
|
33
|
+
if (options.onError) {
|
|
34
|
+
handlerOptions.onError = options.onError;
|
|
35
|
+
}
|
|
36
|
+
return async (request, response, next) => {
|
|
37
|
+
try {
|
|
38
|
+
const webRequest = toWebRequest(request);
|
|
39
|
+
const nextWrapper = () => new Promise((resolve) => {
|
|
40
|
+
const done = () => resolve();
|
|
41
|
+
if (response.on) {
|
|
42
|
+
response.on("finish", done);
|
|
43
|
+
response.on("close", done);
|
|
44
|
+
}
|
|
45
|
+
next();
|
|
46
|
+
if (!response.on) {
|
|
47
|
+
done();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
await handleMiddleware(extractor, webRequest, nextWrapper, handlerOptions);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
next(error);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function betterAuditExpress(options = {}) {
|
|
57
|
+
return createExpressMiddleware(options);
|
|
58
|
+
}
|
|
59
|
+
export {
|
|
60
|
+
betterAuditExpress,
|
|
61
|
+
createExpressMiddleware
|
|
62
|
+
};
|
|
63
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type {\n AuditContext,\n ContextExtractor,\n MiddlewareHandlerOptions,\n} from \"@usebetterdev/audit-core\";\nimport {\n handleMiddleware,\n fromBearerToken,\n} from \"@usebetterdev/audit-core\";\n\n// ---------------------------------------------------------------------------\n// Interface types\n// ---------------------------------------------------------------------------\n\nexport interface ExpressRequestLike {\n headers: Record<string, string | string[] | number | undefined>;\n url: string;\n originalUrl?: string;\n hostname?: string;\n method?: string;\n}\n\nexport interface ExpressResponseLike {\n /** Used to detect when the response is finished so the ALS scope stays open. Real Express responses always have this (inherited from http.ServerResponse). */\n on?: (event: string, listener: (...args: unknown[]) => void) => unknown;\n}\n\nexport type ExpressNextFunction = (error?: unknown) => void;\n\nexport type ExpressMiddleware = (\n request: ExpressRequestLike,\n response: ExpressResponseLike,\n next: ExpressNextFunction,\n) => Promise<void>;\n\nexport interface CreateExpressMiddlewareOptions {\n /** Context extractor for pulling actor identity from the request. */\n extractor?: ContextExtractor;\n /** Called when an extractor throws. Defaults to silent fail-open. */\n onError?: (error: unknown) => void;\n}\n\n// ---------------------------------------------------------------------------\n// Request bridging\n// ---------------------------------------------------------------------------\n\n/**\n * Bridge an Express `IncomingMessage`-shaped request to a Web `Request`.\n *\n * Node.js headers can be `string | string[] | number | undefined`.\n * This normalises them to the `string` values `Headers` expects:\n * - `string[]` → joined with `\", \"`\n * - `number` → converted with `String()`\n * - `undefined`→ skipped\n *\n * **Security note:** `req.hostname` comes from the `Host` header and is\n * unvalidated user input. The reconstructed URL is used only to satisfy the\n * Web `Request` constructor — built-in extractors read headers, not the URL\n * authority. Custom extractors must not trust `new URL(request.url).hostname`.\n */\nfunction toWebRequest(req: ExpressRequestLike): Request {\n const headers = new Headers();\n for (const [key, value] of Object.entries(req.headers)) {\n if (value === undefined) {\n continue;\n }\n if (Array.isArray(value)) {\n headers.set(key, value.join(\", \"));\n } else if (typeof value === \"number\") {\n headers.set(key, String(value));\n } else {\n headers.set(key, value);\n }\n }\n\n const rawPath = req.originalUrl ?? req.url;\n const url = rawPath.startsWith(\"/\")\n ? `http://${req.hostname ?? \"localhost\"}${rawPath}`\n : rawPath;\n\n return new Request(url, {\n method: req.method ?? \"GET\",\n headers,\n });\n}\n\n// ---------------------------------------------------------------------------\n// Default extractor\n// ---------------------------------------------------------------------------\n\n/** Default extractor: reads `sub` from `Authorization: Bearer <jwt>` as actor. */\nconst defaultExtractor: ContextExtractor = {\n actor: fromBearerToken(\"sub\"),\n};\n\n// ---------------------------------------------------------------------------\n// Middleware\n// ---------------------------------------------------------------------------\n\n/**\n * Creates an Express-compatible middleware that populates audit context\n * via AsyncLocalStorage on every request.\n *\n * Bridges Express's `IncomingMessage` to a Web `Request`, then delegates\n * to the shared `handleMiddleware()` from audit-core.\n *\n * **ALS scope lifetime:** the scope stays open until the response finishes\n * (via `response.on('finish'/'close')`), so audit context is available\n * even inside `await`-ed operations in async route handlers.\n *\n * The middleware is non-blocking: if context extraction fails, the request\n * proceeds without audit context (fail open).\n */\nexport function createExpressMiddleware(\n options: CreateExpressMiddlewareOptions = {},\n): ExpressMiddleware {\n const extractor = options.extractor ?? defaultExtractor;\n const handlerOptions: MiddlewareHandlerOptions = {};\n if (options.onError) {\n handlerOptions.onError = options.onError;\n }\n\n return async (request, response, next) => {\n try {\n const webRequest = toWebRequest(request);\n\n const nextWrapper = (): Promise<void> =>\n new Promise<void>((resolve) => {\n const done = () => resolve();\n if (response.on) {\n response.on(\"finish\", done);\n response.on(\"close\", done);\n }\n next();\n if (!response.on) {\n done();\n }\n });\n\n await handleMiddleware(extractor, webRequest, nextWrapper, handlerOptions);\n } catch (error) {\n next(error);\n }\n };\n}\n\n/**\n * Convenience wrapper: `app.use(betterAuditExpress())`.\n *\n * Uses the default JWT extractor (reads `sub` from `Authorization: Bearer <jwt>`).\n * Pass options to customise extraction.\n */\nexport function betterAuditExpress(\n options: CreateExpressMiddlewareOptions = {},\n): ExpressMiddleware {\n return createExpressMiddleware(options);\n}\n\nexport type { AuditContext, ContextExtractor };\n"],"mappings":";AAKA;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAoDP,SAAS,aAAa,KAAkC;AACtD,QAAM,UAAU,IAAI,QAAQ;AAC5B,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,IAAI,OAAO,GAAG;AACtD,QAAI,UAAU,QAAW;AACvB;AAAA,IACF;AACA,QAAI,MAAM,QAAQ,KAAK,GAAG;AACxB,cAAQ,IAAI,KAAK,MAAM,KAAK,IAAI,CAAC;AAAA,IACnC,WAAW,OAAO,UAAU,UAAU;AACpC,cAAQ,IAAI,KAAK,OAAO,KAAK,CAAC;AAAA,IAChC,OAAO;AACL,cAAQ,IAAI,KAAK,KAAK;AAAA,IACxB;AAAA,EACF;AAEA,QAAM,UAAU,IAAI,eAAe,IAAI;AACvC,QAAM,MAAM,QAAQ,WAAW,GAAG,IAC9B,UAAU,IAAI,YAAY,WAAW,GAAG,OAAO,KAC/C;AAEJ,SAAO,IAAI,QAAQ,KAAK;AAAA,IACtB,QAAQ,IAAI,UAAU;AAAA,IACtB;AAAA,EACF,CAAC;AACH;AAOA,IAAM,mBAAqC;AAAA,EACzC,OAAO,gBAAgB,KAAK;AAC9B;AAoBO,SAAS,wBACd,UAA0C,CAAC,GACxB;AACnB,QAAM,YAAY,QAAQ,aAAa;AACvC,QAAM,iBAA2C,CAAC;AAClD,MAAI,QAAQ,SAAS;AACnB,mBAAe,UAAU,QAAQ;AAAA,EACnC;AAEA,SAAO,OAAO,SAAS,UAAU,SAAS;AACxC,QAAI;AACF,YAAM,aAAa,aAAa,OAAO;AAEvC,YAAM,cAAc,MAClB,IAAI,QAAc,CAAC,YAAY;AAC7B,cAAM,OAAO,MAAM,QAAQ;AAC3B,YAAI,SAAS,IAAI;AACf,mBAAS,GAAG,UAAU,IAAI;AAC1B,mBAAS,GAAG,SAAS,IAAI;AAAA,QAC3B;AACA,aAAK;AACL,YAAI,CAAC,SAAS,IAAI;AAChB,eAAK;AAAA,QACP;AAAA,MACF,CAAC;AAEH,YAAM,iBAAiB,WAAW,YAAY,aAAa,cAAc;AAAA,IAC3E,SAAS,OAAO;AACd,WAAK,KAAK;AAAA,IACZ;AAAA,EACF;AACF;AAQO,SAAS,mBACd,UAA0C,CAAC,GACxB;AACnB,SAAO,wBAAwB,OAAO;AACxC;","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@usebetterdev/audit-express",
|
|
3
|
+
"version": "0.5.0-beta.1",
|
|
4
|
+
"repository": "github:usebetter-dev/usebetter",
|
|
5
|
+
"bugs": "https://github.com/usebetter-dev/usebetter/issues",
|
|
6
|
+
"homepage": "https://github.com/usebetter-dev/usebetter#readme",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public",
|
|
9
|
+
"registry": "https://registry.npmjs.org/"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "./dist/index.cjs",
|
|
13
|
+
"module": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js",
|
|
19
|
+
"require": "./dist/index.cjs"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsup",
|
|
28
|
+
"lint": "oxlint",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"typecheck": "tsc --noEmit"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@usebetterdev/audit-core": "workspace:*"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"express": ">=4"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/express": "^4.17.21",
|
|
40
|
+
"@types/node": "^22.10.0",
|
|
41
|
+
"@types/supertest": "^6.0.2",
|
|
42
|
+
"express": "^4.21.2",
|
|
43
|
+
"supertest": "^7.0.0",
|
|
44
|
+
"tsup": "^8.3.5",
|
|
45
|
+
"typescript": "~5.7.2",
|
|
46
|
+
"vitest": "^2.1.6"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=22"
|
|
50
|
+
}
|
|
51
|
+
}
|