@usebetterdev/audit-next 0.6.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 +189 -0
- package/dist/index.cjs +127 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +140 -0
- package/dist/index.d.ts +140 -0
- package/dist/index.js +96 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# @usebetterdev/audit-next
|
|
2
|
+
|
|
3
|
+
Next.js App Router adapter for [`@usebetterdev/audit-core`](../core). Extracts actor identity from incoming requests and propagates it via `AsyncLocalStorage` so that every audit log captured during a request automatically includes the correct actor.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @usebetterdev/audit-next @usebetterdev/audit-core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires `next >= 14`.
|
|
12
|
+
|
|
13
|
+
## How context propagation works in Next.js
|
|
14
|
+
|
|
15
|
+
Next.js App Router has three distinct execution contexts, each requiring a different approach:
|
|
16
|
+
|
|
17
|
+
| Context | Has `Request`? | ALS propagates? | Use |
|
|
18
|
+
|---|---|---|---|
|
|
19
|
+
| Edge Middleware (`middleware.ts`) | Yes | No — separate isolate | `createAuditMiddleware` |
|
|
20
|
+
| Route Handler (`route.ts`) | Yes | Yes | `withAuditRoute` |
|
|
21
|
+
| Server Action | No | Yes | `withAudit` |
|
|
22
|
+
|
|
23
|
+
**Edge Middleware runs in a separate V8 isolate from route handlers.** AsyncLocalStorage set in middleware does not carry over. Instead, `createAuditMiddleware` extracts the actor from the request and forwards it as a request header (`x-better-audit-actor-id` by default). `withAuditRoute` then reads that header and sets up ALS for the handler's execution.
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
### Option A — Middleware + route handlers (recommended for APIs)
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// middleware.ts
|
|
31
|
+
import { createAuditMiddleware } from "@usebetterdev/audit-next";
|
|
32
|
+
|
|
33
|
+
export default createAuditMiddleware();
|
|
34
|
+
export const config = { matcher: "/api/:path*" };
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// app/api/orders/route.ts
|
|
39
|
+
import { withAuditRoute, fromHeader, AUDIT_ACTOR_HEADER, getAuditContext } from "@usebetterdev/audit-next";
|
|
40
|
+
import { audit } from "@/lib/audit";
|
|
41
|
+
|
|
42
|
+
async function handler(request: NextRequest) {
|
|
43
|
+
const ctx = getAuditContext(); // { actorId: "user-123" }
|
|
44
|
+
await audit.captureLog({ tableName: "orders", operation: "INSERT", recordId: "ord-1", after: {} });
|
|
45
|
+
return Response.json({ ok: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const POST = withAuditRoute(handler, {
|
|
49
|
+
extractor: { actor: fromHeader(AUDIT_ACTOR_HEADER) },
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
> Use `AUDIT_ACTOR_HEADER` to keep the header name in sync between middleware and route handlers without hardcoding strings.
|
|
54
|
+
|
|
55
|
+
### Option B — Route handler only (no middleware)
|
|
56
|
+
|
|
57
|
+
If you don't use Next.js middleware, `withAuditRoute` can extract directly from the request:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
// app/api/orders/route.ts
|
|
61
|
+
import { withAuditRoute } from "@usebetterdev/audit-next";
|
|
62
|
+
|
|
63
|
+
export const POST = withAuditRoute(handler);
|
|
64
|
+
// Reads `sub` from Authorization: Bearer <jwt> by default
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Option C — Server actions
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
// app/actions.ts
|
|
71
|
+
"use server";
|
|
72
|
+
import { withAudit, getAuditContext } from "@usebetterdev/audit-next";
|
|
73
|
+
import { audit } from "@/lib/audit";
|
|
74
|
+
|
|
75
|
+
export const createOrder = withAudit(async (formData: FormData) => {
|
|
76
|
+
const ctx = getAuditContext(); // { actorId: "user-123" }
|
|
77
|
+
await audit.captureLog({ tableName: "orders", operation: "INSERT", recordId: "ord-1", after: {} });
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Server actions don't receive a `Request` object. `withAudit` reads all request headers via `next/headers` and constructs a synthetic request to run the extractor against.
|
|
82
|
+
|
|
83
|
+
## Actor extraction
|
|
84
|
+
|
|
85
|
+
All three wrappers use a `ContextExtractor` to resolve the actor. The default extracts the `sub` claim from `Authorization: Bearer <jwt>`. The token is decoded **without** signature verification — that is the auth layer's responsibility.
|
|
86
|
+
|
|
87
|
+
### Custom JWT claim
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { fromBearerToken } from "@usebetterdev/audit-next";
|
|
91
|
+
|
|
92
|
+
withAuditRoute(handler, {
|
|
93
|
+
extractor: { actor: fromBearerToken("user_id") },
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Header-based extraction
|
|
98
|
+
|
|
99
|
+
Common when running behind an API gateway that forwards identity as a plain header:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { fromHeader } from "@usebetterdev/audit-next";
|
|
103
|
+
|
|
104
|
+
withAuditRoute(handler, {
|
|
105
|
+
extractor: { actor: fromHeader("x-user-id") },
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Cookie-based extraction
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { fromCookie } from "@usebetterdev/audit-next";
|
|
113
|
+
|
|
114
|
+
withAudit(action, {
|
|
115
|
+
extractor: { actor: fromCookie("session_id") },
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Custom extractor
|
|
120
|
+
|
|
121
|
+
Write any async function that receives a `Request` and returns a string or `undefined`:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
withAuditRoute(handler, {
|
|
125
|
+
extractor: {
|
|
126
|
+
actor: async (request) => {
|
|
127
|
+
const key = request.headers.get("x-api-key");
|
|
128
|
+
if (!key) return undefined;
|
|
129
|
+
const owner = await resolveApiKeyOwner(key);
|
|
130
|
+
return owner?.id;
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Error handling
|
|
137
|
+
|
|
138
|
+
All wrappers **fail open** — if extraction fails, the request or action proceeds without audit context. No request is ever blocked by the audit layer.
|
|
139
|
+
|
|
140
|
+
Use `onError` to observe failures:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
createAuditMiddleware({
|
|
144
|
+
onError: (error) => console.error("Audit extraction failed:", error),
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Security note
|
|
149
|
+
|
|
150
|
+
`createAuditMiddleware` always overwrites the actor header on the forwarded request — including when extraction fails (sets it to `""`). This prevents clients from spoofing the actor identity by sending the header directly. **Do not trust `x-better-audit-actor-id` on incoming requests unless it was set by your middleware.**
|
|
151
|
+
|
|
152
|
+
## API
|
|
153
|
+
|
|
154
|
+
### `createAuditMiddleware(options?)`
|
|
155
|
+
|
|
156
|
+
Creates a Next.js edge middleware function. Extracts actor from the request and forwards it as a request header to downstream route handlers.
|
|
157
|
+
|
|
158
|
+
### `betterAuditNext(options?)`
|
|
159
|
+
|
|
160
|
+
Convenience alias for `createAuditMiddleware`. Use as the default export in `middleware.ts`.
|
|
161
|
+
|
|
162
|
+
### `withAuditRoute(handler, options?)`
|
|
163
|
+
|
|
164
|
+
Wraps an App Router route handler. Extracts actor from the incoming `NextRequest` and runs the handler inside an ALS scope.
|
|
165
|
+
|
|
166
|
+
### `withAudit(action, options?)`
|
|
167
|
+
|
|
168
|
+
Wraps a server action. Reads headers via `next/headers`, extracts actor, and runs the action inside an ALS scope.
|
|
169
|
+
|
|
170
|
+
**Options (all wrappers):**
|
|
171
|
+
|
|
172
|
+
| Option | Type | Description |
|
|
173
|
+
|---|---|---|
|
|
174
|
+
| `extractor` | `ContextExtractor` | Actor extractor config. Defaults to JWT `sub` claim. |
|
|
175
|
+
| `onError` | `(error: unknown) => void` | Called when extraction throws. Defaults to no-op. |
|
|
176
|
+
|
|
177
|
+
**Additional option for `createAuditMiddleware`:**
|
|
178
|
+
|
|
179
|
+
| Option | Type | Description |
|
|
180
|
+
|---|---|---|
|
|
181
|
+
| `actorHeader` | `string` | Header name for forwarding the actor id. Defaults to `AUDIT_ACTOR_HEADER`. |
|
|
182
|
+
|
|
183
|
+
### `AUDIT_ACTOR_HEADER`
|
|
184
|
+
|
|
185
|
+
The default header name (`"x-better-audit-actor-id"`) used to forward the actor id from middleware to route handlers. Import this constant in both files to avoid hardcoding the string.
|
|
186
|
+
|
|
187
|
+
### `getAuditContext()`
|
|
188
|
+
|
|
189
|
+
Returns the current `AuditContext` from ALS, or `undefined` when called outside a request scope. Re-exported from `@usebetterdev/audit-core` for convenience.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
AUDIT_ACTOR_HEADER: () => AUDIT_ACTOR_HEADER,
|
|
24
|
+
betterAuditNext: () => betterAuditNext,
|
|
25
|
+
createAuditMiddleware: () => createAuditMiddleware,
|
|
26
|
+
fromBearerToken: () => import_audit_core2.fromBearerToken,
|
|
27
|
+
fromCookie: () => import_audit_core2.fromCookie,
|
|
28
|
+
fromHeader: () => import_audit_core2.fromHeader,
|
|
29
|
+
getAuditContext: () => import_audit_core2.getAuditContext,
|
|
30
|
+
runWithAuditContext: () => import_audit_core2.runWithAuditContext,
|
|
31
|
+
withAudit: () => withAudit,
|
|
32
|
+
withAuditRoute: () => withAuditRoute
|
|
33
|
+
});
|
|
34
|
+
module.exports = __toCommonJS(index_exports);
|
|
35
|
+
var import_audit_core = require("@usebetterdev/audit-core");
|
|
36
|
+
var import_headers = require("next/headers");
|
|
37
|
+
var import_server = require("next/server");
|
|
38
|
+
var import_audit_core2 = require("@usebetterdev/audit-core");
|
|
39
|
+
var AUDIT_ACTOR_HEADER = "x-better-audit-actor-id";
|
|
40
|
+
var defaultExtractor = {
|
|
41
|
+
actor: (0, import_audit_core.fromBearerToken)("sub")
|
|
42
|
+
};
|
|
43
|
+
async function tryExtract(extractor, request, onError) {
|
|
44
|
+
if (!extractor) {
|
|
45
|
+
return void 0;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
return await extractor(request);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (onError) {
|
|
51
|
+
onError(error);
|
|
52
|
+
}
|
|
53
|
+
return void 0;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function createAuditMiddleware(options = {}) {
|
|
57
|
+
const extractor = options.extractor ?? defaultExtractor;
|
|
58
|
+
const actorHeader = options.actorHeader ?? AUDIT_ACTOR_HEADER;
|
|
59
|
+
return async (request) => {
|
|
60
|
+
const actorId = await tryExtract(
|
|
61
|
+
extractor.actor,
|
|
62
|
+
request,
|
|
63
|
+
options.onError
|
|
64
|
+
);
|
|
65
|
+
const requestHeaders = new Headers(request.headers);
|
|
66
|
+
requestHeaders.set(actorHeader, actorId ?? "");
|
|
67
|
+
return import_server.NextResponse.next({
|
|
68
|
+
request: { headers: requestHeaders }
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function betterAuditNext(options = {}) {
|
|
73
|
+
return createAuditMiddleware(options);
|
|
74
|
+
}
|
|
75
|
+
function withAuditRoute(handler, options = {}) {
|
|
76
|
+
const extractor = options.extractor ?? defaultExtractor;
|
|
77
|
+
return async (request, context) => {
|
|
78
|
+
const actorId = await tryExtract(
|
|
79
|
+
extractor.actor,
|
|
80
|
+
request,
|
|
81
|
+
options.onError
|
|
82
|
+
);
|
|
83
|
+
if (actorId === void 0) {
|
|
84
|
+
return handler(request, context);
|
|
85
|
+
}
|
|
86
|
+
const auditContext = { actorId };
|
|
87
|
+
return (0, import_audit_core.runWithAuditContext)(auditContext, () => handler(request, context));
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function withAudit(action, options = {}) {
|
|
91
|
+
const extractor = options.extractor ?? defaultExtractor;
|
|
92
|
+
return async (...args) => {
|
|
93
|
+
let actorId;
|
|
94
|
+
try {
|
|
95
|
+
const headersList = await (0, import_headers.headers)();
|
|
96
|
+
const syntheticRequest = new Request("http://localhost", {
|
|
97
|
+
headers: headersList
|
|
98
|
+
});
|
|
99
|
+
actorId = await tryExtract(
|
|
100
|
+
extractor.actor,
|
|
101
|
+
syntheticRequest,
|
|
102
|
+
options.onError
|
|
103
|
+
);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
options.onError?.(error);
|
|
106
|
+
}
|
|
107
|
+
if (actorId === void 0) {
|
|
108
|
+
return action(...args);
|
|
109
|
+
}
|
|
110
|
+
const auditContext = { actorId };
|
|
111
|
+
return (0, import_audit_core.runWithAuditContext)(auditContext, () => action(...args));
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
115
|
+
0 && (module.exports = {
|
|
116
|
+
AUDIT_ACTOR_HEADER,
|
|
117
|
+
betterAuditNext,
|
|
118
|
+
createAuditMiddleware,
|
|
119
|
+
fromBearerToken,
|
|
120
|
+
fromCookie,
|
|
121
|
+
fromHeader,
|
|
122
|
+
getAuditContext,
|
|
123
|
+
runWithAuditContext,
|
|
124
|
+
withAudit,
|
|
125
|
+
withAuditRoute
|
|
126
|
+
});
|
|
127
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["import {\n fromBearerToken,\n getAuditContext,\n runWithAuditContext,\n} from \"@usebetterdev/audit-core\";\nimport type {\n AuditContext,\n ContextExtractor,\n ValueExtractor,\n} from \"@usebetterdev/audit-core\";\nimport { headers } from \"next/headers\";\nimport { NextResponse } from \"next/server\";\nimport type { NextRequest } from \"next/server\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport type RouteContext = Record<string, unknown> | undefined;\n\nexport type NextMiddleware = (request: NextRequest) => Promise<NextResponse>;\n\n/**\n * Shared options across all audit wrappers.\n */\nexport interface BaseAuditOptions {\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\nexport interface CreateAuditMiddlewareOptions extends BaseAuditOptions {\n /**\n * Request header name used to forward the extracted actor id to downstream\n * route handlers. Defaults to `AUDIT_ACTOR_HEADER`.\n */\n actorHeader?: string;\n}\n\nexport type WithAuditRouteOptions = BaseAuditOptions;\n\nexport type WithAuditOptions = BaseAuditOptions;\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/**\n * Default header name used to forward the actor id from middleware to route\n * handlers.\n *\n * Use this constant to keep the middleware and route handler in sync:\n * ```ts\n * // middleware.ts\n * export default createAuditMiddleware(); // sets AUDIT_ACTOR_HEADER\n *\n * // app/api/orders/route.ts\n * export const GET = withAuditRoute(handler, {\n * extractor: { actor: fromHeader(AUDIT_ACTOR_HEADER) },\n * });\n * ```\n */\nexport const AUDIT_ACTOR_HEADER = \"x-better-audit-actor-id\";\n\n// ---------------------------------------------------------------------------\n// Private helpers\n// ---------------------------------------------------------------------------\n\n/** Default extractor: reads `sub` from `Authorization: Bearer <jwt>` as actor. */\nconst defaultExtractor: ContextExtractor = {\n actor: fromBearerToken(\"sub\"),\n};\n\n/**\n * Runs a value extractor safely, returning undefined on error (fail open).\n */\nasync function tryExtract(\n extractor: ValueExtractor | undefined,\n request: Request,\n onError: ((error: unknown) => void) | undefined,\n): Promise<string | undefined> {\n if (!extractor) {\n return undefined;\n }\n try {\n return await extractor(request);\n } catch (error: unknown) {\n if (onError) {\n onError(error);\n }\n return undefined;\n }\n}\n\n// ---------------------------------------------------------------------------\n// createAuditMiddleware / betterAuditNext\n// ---------------------------------------------------------------------------\n\n/**\n * Creates a Next.js edge middleware function that extracts audit context from\n * the incoming request and forwards the actor id as a request header to\n * downstream route handlers.\n *\n * **Important:** Next.js middleware runs in a separate Edge Runtime execution\n * context from route handlers — AsyncLocalStorage set here does NOT propagate.\n * This middleware forwards the extracted actor id via a request header\n * (`x-better-audit-actor-id` by default). Use `withAuditRoute` in your route\n * handlers to read that header and populate ALS.\n *\n * The middleware **always** overwrites the actor header on the forwarded\n * request — when extraction fails, it is set to an empty string. This prevents\n * clients from spoofing the actor id by sending the header directly.\n *\n * The middleware is non-blocking: if context extraction fails, the request\n * proceeds with an empty actor header (fail open).\n *\n * @example\n * ```ts\n * // middleware.ts\n * import { createAuditMiddleware } from \"@usebetterdev/audit-next\";\n * export default createAuditMiddleware();\n * export const config = { matcher: \"/api/:path*\" };\n *\n * // app/api/orders/route.ts\n * import { withAuditRoute, fromHeader, AUDIT_ACTOR_HEADER } from \"@usebetterdev/audit-next\";\n * export const GET = withAuditRoute(handler, {\n * extractor: { actor: fromHeader(AUDIT_ACTOR_HEADER) },\n * });\n * ```\n */\nexport function createAuditMiddleware(\n options: CreateAuditMiddlewareOptions = {},\n): NextMiddleware {\n const extractor = options.extractor ?? defaultExtractor;\n const actorHeader = options.actorHeader ?? AUDIT_ACTOR_HEADER;\n\n return async (request) => {\n const actorId = await tryExtract(\n extractor.actor,\n request,\n options.onError,\n );\n\n // Always forward with an explicit header value so clients cannot spoof\n // the actor id by sending the header directly. When extraction fails the\n // header is set to \"\" — fromHeader() treats empty values as undefined.\n const requestHeaders = new Headers(request.headers);\n requestHeaders.set(actorHeader, actorId ?? \"\");\n\n return NextResponse.next({\n request: { headers: requestHeaders },\n });\n };\n}\n\n/**\n * Convenience alias for `createAuditMiddleware`. Use as the default export\n * in `middleware.ts`.\n *\n * @example\n * ```ts\n * // middleware.ts\n * import { betterAuditNext } from \"@usebetterdev/audit-next\";\n * export default betterAuditNext();\n * ```\n */\nexport function betterAuditNext(\n options: CreateAuditMiddlewareOptions = {},\n): NextMiddleware {\n return createAuditMiddleware(options);\n}\n\n// ---------------------------------------------------------------------------\n// withAuditRoute\n// ---------------------------------------------------------------------------\n\n/**\n * Wraps a Next.js App Router route handler with audit context propagation.\n *\n * Extracts the actor identity from the incoming request (by default reads\n * `sub` from `Authorization: Bearer <jwt>`, or reads the\n * `x-better-audit-actor-id` header forwarded by `createAuditMiddleware`) and\n * runs the handler inside an AsyncLocalStorage scope so that `getAuditContext()`\n * returns the correct context within the handler.\n *\n * `NextRequest` is a Web `Request` subclass — extractors from audit-core\n * (`fromBearerToken`, `fromHeader`, `fromCookie`) work directly.\n *\n * The wrapper is non-blocking: if extraction fails, the handler runs without\n * audit context (fail open).\n *\n * @example\n * ```ts\n * // app/api/orders/route.ts — standalone (no middleware)\n * import { withAuditRoute } from \"@usebetterdev/audit-next\";\n * export const GET = withAuditRoute(handler);\n *\n * // app/api/orders/route.ts — paired with createAuditMiddleware\n * import { withAuditRoute, fromHeader, AUDIT_ACTOR_HEADER } from \"@usebetterdev/audit-next\";\n * export const GET = withAuditRoute(handler, {\n * extractor: { actor: fromHeader(AUDIT_ACTOR_HEADER) },\n * });\n * ```\n */\nexport function withAuditRoute<C extends RouteContext = RouteContext>(\n handler: (request: NextRequest, context: C) => Promise<Response>,\n options: WithAuditRouteOptions = {},\n): (request: NextRequest, context: C) => Promise<Response> {\n const extractor = options.extractor ?? defaultExtractor;\n\n return async (request, context) => {\n const actorId = await tryExtract(\n extractor.actor,\n request,\n options.onError,\n );\n\n if (actorId === undefined) {\n return handler(request, context);\n }\n\n const auditContext: AuditContext = { actorId };\n\n return runWithAuditContext(auditContext, () => handler(request, context));\n };\n}\n\n// ---------------------------------------------------------------------------\n// withAudit (server actions)\n// ---------------------------------------------------------------------------\n\n/**\n * Wraps a Next.js server action with audit context propagation.\n *\n * Server actions do not receive a `Request` object. This wrapper reads all\n * request headers via `next/headers` and constructs a synthetic `Request` to\n * run the configured extractor against, then wraps the action in an\n * AsyncLocalStorage scope so that `getAuditContext()` returns the correct\n * context within the action.\n *\n * The wrapper is non-blocking: if extraction fails (including when `headers()`\n * is unavailable), the action runs without audit context (fail open).\n *\n * @example\n * ```ts\n * // app/actions.ts\n * \"use server\";\n * import { withAudit } from \"@usebetterdev/audit-next\";\n *\n * export const createOrder = withAudit(async (formData: FormData) => {\n * // getAuditContext() returns the actor here\n * });\n * ```\n */\nexport function withAudit<TArgs extends unknown[], TResult>(\n action: (...args: TArgs) => Promise<TResult>,\n options: WithAuditOptions = {},\n): (...args: TArgs) => Promise<TResult> {\n const extractor = options.extractor ?? defaultExtractor;\n\n return async (...args) => {\n let actorId: string | undefined;\n\n try {\n const headersList = await headers();\n const syntheticRequest = new Request(\"http://localhost\", {\n headers: headersList,\n });\n actorId = await tryExtract(\n extractor.actor,\n syntheticRequest,\n options.onError,\n );\n } catch (error: unknown) {\n // headers() throws outside Next.js request context — fail open\n options.onError?.(error);\n }\n\n if (actorId === undefined) {\n return action(...args);\n }\n\n const auditContext: AuditContext = { actorId };\n\n return runWithAuditContext(auditContext, () => action(...args));\n };\n}\n\n// ---------------------------------------------------------------------------\n// Re-exports\n// ---------------------------------------------------------------------------\n\nexport type { AuditContext, ContextExtractor, ValueExtractor };\nexport { fromBearerToken, fromCookie, fromHeader, getAuditContext, runWithAuditContext } from \"@usebetterdev/audit-core\";\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,wBAIO;AAMP,qBAAwB;AACxB,oBAA6B;AA2R7B,IAAAA,qBAA8F;AAvOvF,IAAM,qBAAqB;AAOlC,IAAM,mBAAqC;AAAA,EACzC,WAAO,mCAAgB,KAAK;AAC9B;AAKA,eAAe,WACb,WACA,SACA,SAC6B;AAC7B,MAAI,CAAC,WAAW;AACd,WAAO;AAAA,EACT;AACA,MAAI;AACF,WAAO,MAAM,UAAU,OAAO;AAAA,EAChC,SAAS,OAAgB;AACvB,QAAI,SAAS;AACX,cAAQ,KAAK;AAAA,IACf;AACA,WAAO;AAAA,EACT;AACF;AAsCO,SAAS,sBACd,UAAwC,CAAC,GACzB;AAChB,QAAM,YAAY,QAAQ,aAAa;AACvC,QAAM,cAAc,QAAQ,eAAe;AAE3C,SAAO,OAAO,YAAY;AACxB,UAAM,UAAU,MAAM;AAAA,MACpB,UAAU;AAAA,MACV;AAAA,MACA,QAAQ;AAAA,IACV;AAKA,UAAM,iBAAiB,IAAI,QAAQ,QAAQ,OAAO;AAClD,mBAAe,IAAI,aAAa,WAAW,EAAE;AAE7C,WAAO,2BAAa,KAAK;AAAA,MACvB,SAAS,EAAE,SAAS,eAAe;AAAA,IACrC,CAAC;AAAA,EACH;AACF;AAaO,SAAS,gBACd,UAAwC,CAAC,GACzB;AAChB,SAAO,sBAAsB,OAAO;AACtC;AAkCO,SAAS,eACd,SACA,UAAiC,CAAC,GACuB;AACzD,QAAM,YAAY,QAAQ,aAAa;AAEvC,SAAO,OAAO,SAAS,YAAY;AACjC,UAAM,UAAU,MAAM;AAAA,MACpB,UAAU;AAAA,MACV;AAAA,MACA,QAAQ;AAAA,IACV;AAEA,QAAI,YAAY,QAAW;AACzB,aAAO,QAAQ,SAAS,OAAO;AAAA,IACjC;AAEA,UAAM,eAA6B,EAAE,QAAQ;AAE7C,eAAO,uCAAoB,cAAc,MAAM,QAAQ,SAAS,OAAO,CAAC;AAAA,EAC1E;AACF;AA6BO,SAAS,UACd,QACA,UAA4B,CAAC,GACS;AACtC,QAAM,YAAY,QAAQ,aAAa;AAEvC,SAAO,UAAU,SAAS;AACxB,QAAI;AAEJ,QAAI;AACF,YAAM,cAAc,UAAM,wBAAQ;AAClC,YAAM,mBAAmB,IAAI,QAAQ,oBAAoB;AAAA,QACvD,SAAS;AAAA,MACX,CAAC;AACD,gBAAU,MAAM;AAAA,QACd,UAAU;AAAA,QACV;AAAA,QACA,QAAQ;AAAA,MACV;AAAA,IACF,SAAS,OAAgB;AAEvB,cAAQ,UAAU,KAAK;AAAA,IACzB;AAEA,QAAI,YAAY,QAAW;AACzB,aAAO,OAAO,GAAG,IAAI;AAAA,IACvB;AAEA,UAAM,eAA6B,EAAE,QAAQ;AAE7C,eAAO,uCAAoB,cAAc,MAAM,OAAO,GAAG,IAAI,CAAC;AAAA,EAChE;AACF;","names":["import_audit_core"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { ContextExtractor } from '@usebetterdev/audit-core';
|
|
2
|
+
export { AuditContext, ContextExtractor, ValueExtractor, fromBearerToken, fromCookie, fromHeader, getAuditContext, runWithAuditContext } from '@usebetterdev/audit-core';
|
|
3
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
4
|
+
|
|
5
|
+
type RouteContext = Record<string, unknown> | undefined;
|
|
6
|
+
type NextMiddleware = (request: NextRequest) => Promise<NextResponse>;
|
|
7
|
+
/**
|
|
8
|
+
* Shared options across all audit wrappers.
|
|
9
|
+
*/
|
|
10
|
+
interface BaseAuditOptions {
|
|
11
|
+
/** Context extractor for pulling actor identity from the request. */
|
|
12
|
+
extractor?: ContextExtractor;
|
|
13
|
+
/** Called when an extractor throws. Defaults to silent fail-open. */
|
|
14
|
+
onError?: (error: unknown) => void;
|
|
15
|
+
}
|
|
16
|
+
interface CreateAuditMiddlewareOptions extends BaseAuditOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Request header name used to forward the extracted actor id to downstream
|
|
19
|
+
* route handlers. Defaults to `AUDIT_ACTOR_HEADER`.
|
|
20
|
+
*/
|
|
21
|
+
actorHeader?: string;
|
|
22
|
+
}
|
|
23
|
+
type WithAuditRouteOptions = BaseAuditOptions;
|
|
24
|
+
type WithAuditOptions = BaseAuditOptions;
|
|
25
|
+
/**
|
|
26
|
+
* Default header name used to forward the actor id from middleware to route
|
|
27
|
+
* handlers.
|
|
28
|
+
*
|
|
29
|
+
* Use this constant to keep the middleware and route handler in sync:
|
|
30
|
+
* ```ts
|
|
31
|
+
* // middleware.ts
|
|
32
|
+
* export default createAuditMiddleware(); // sets AUDIT_ACTOR_HEADER
|
|
33
|
+
*
|
|
34
|
+
* // app/api/orders/route.ts
|
|
35
|
+
* export const GET = withAuditRoute(handler, {
|
|
36
|
+
* extractor: { actor: fromHeader(AUDIT_ACTOR_HEADER) },
|
|
37
|
+
* });
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
declare const AUDIT_ACTOR_HEADER = "x-better-audit-actor-id";
|
|
41
|
+
/**
|
|
42
|
+
* Creates a Next.js edge middleware function that extracts audit context from
|
|
43
|
+
* the incoming request and forwards the actor id as a request header to
|
|
44
|
+
* downstream route handlers.
|
|
45
|
+
*
|
|
46
|
+
* **Important:** Next.js middleware runs in a separate Edge Runtime execution
|
|
47
|
+
* context from route handlers — AsyncLocalStorage set here does NOT propagate.
|
|
48
|
+
* This middleware forwards the extracted actor id via a request header
|
|
49
|
+
* (`x-better-audit-actor-id` by default). Use `withAuditRoute` in your route
|
|
50
|
+
* handlers to read that header and populate ALS.
|
|
51
|
+
*
|
|
52
|
+
* The middleware **always** overwrites the actor header on the forwarded
|
|
53
|
+
* request — when extraction fails, it is set to an empty string. This prevents
|
|
54
|
+
* clients from spoofing the actor id by sending the header directly.
|
|
55
|
+
*
|
|
56
|
+
* The middleware is non-blocking: if context extraction fails, the request
|
|
57
|
+
* proceeds with an empty actor header (fail open).
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* // middleware.ts
|
|
62
|
+
* import { createAuditMiddleware } from "@usebetterdev/audit-next";
|
|
63
|
+
* export default createAuditMiddleware();
|
|
64
|
+
* export const config = { matcher: "/api/:path*" };
|
|
65
|
+
*
|
|
66
|
+
* // app/api/orders/route.ts
|
|
67
|
+
* import { withAuditRoute, fromHeader, AUDIT_ACTOR_HEADER } from "@usebetterdev/audit-next";
|
|
68
|
+
* export const GET = withAuditRoute(handler, {
|
|
69
|
+
* extractor: { actor: fromHeader(AUDIT_ACTOR_HEADER) },
|
|
70
|
+
* });
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
declare function createAuditMiddleware(options?: CreateAuditMiddlewareOptions): NextMiddleware;
|
|
74
|
+
/**
|
|
75
|
+
* Convenience alias for `createAuditMiddleware`. Use as the default export
|
|
76
|
+
* in `middleware.ts`.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* // middleware.ts
|
|
81
|
+
* import { betterAuditNext } from "@usebetterdev/audit-next";
|
|
82
|
+
* export default betterAuditNext();
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
declare function betterAuditNext(options?: CreateAuditMiddlewareOptions): NextMiddleware;
|
|
86
|
+
/**
|
|
87
|
+
* Wraps a Next.js App Router route handler with audit context propagation.
|
|
88
|
+
*
|
|
89
|
+
* Extracts the actor identity from the incoming request (by default reads
|
|
90
|
+
* `sub` from `Authorization: Bearer <jwt>`, or reads the
|
|
91
|
+
* `x-better-audit-actor-id` header forwarded by `createAuditMiddleware`) and
|
|
92
|
+
* runs the handler inside an AsyncLocalStorage scope so that `getAuditContext()`
|
|
93
|
+
* returns the correct context within the handler.
|
|
94
|
+
*
|
|
95
|
+
* `NextRequest` is a Web `Request` subclass — extractors from audit-core
|
|
96
|
+
* (`fromBearerToken`, `fromHeader`, `fromCookie`) work directly.
|
|
97
|
+
*
|
|
98
|
+
* The wrapper is non-blocking: if extraction fails, the handler runs without
|
|
99
|
+
* audit context (fail open).
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```ts
|
|
103
|
+
* // app/api/orders/route.ts — standalone (no middleware)
|
|
104
|
+
* import { withAuditRoute } from "@usebetterdev/audit-next";
|
|
105
|
+
* export const GET = withAuditRoute(handler);
|
|
106
|
+
*
|
|
107
|
+
* // app/api/orders/route.ts — paired with createAuditMiddleware
|
|
108
|
+
* import { withAuditRoute, fromHeader, AUDIT_ACTOR_HEADER } from "@usebetterdev/audit-next";
|
|
109
|
+
* export const GET = withAuditRoute(handler, {
|
|
110
|
+
* extractor: { actor: fromHeader(AUDIT_ACTOR_HEADER) },
|
|
111
|
+
* });
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
declare function withAuditRoute<C extends RouteContext = RouteContext>(handler: (request: NextRequest, context: C) => Promise<Response>, options?: WithAuditRouteOptions): (request: NextRequest, context: C) => Promise<Response>;
|
|
115
|
+
/**
|
|
116
|
+
* Wraps a Next.js server action with audit context propagation.
|
|
117
|
+
*
|
|
118
|
+
* Server actions do not receive a `Request` object. This wrapper reads all
|
|
119
|
+
* request headers via `next/headers` and constructs a synthetic `Request` to
|
|
120
|
+
* run the configured extractor against, then wraps the action in an
|
|
121
|
+
* AsyncLocalStorage scope so that `getAuditContext()` returns the correct
|
|
122
|
+
* context within the action.
|
|
123
|
+
*
|
|
124
|
+
* The wrapper is non-blocking: if extraction fails (including when `headers()`
|
|
125
|
+
* is unavailable), the action runs without audit context (fail open).
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* ```ts
|
|
129
|
+
* // app/actions.ts
|
|
130
|
+
* "use server";
|
|
131
|
+
* import { withAudit } from "@usebetterdev/audit-next";
|
|
132
|
+
*
|
|
133
|
+
* export const createOrder = withAudit(async (formData: FormData) => {
|
|
134
|
+
* // getAuditContext() returns the actor here
|
|
135
|
+
* });
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
declare function withAudit<TArgs extends unknown[], TResult>(action: (...args: TArgs) => Promise<TResult>, options?: WithAuditOptions): (...args: TArgs) => Promise<TResult>;
|
|
139
|
+
|
|
140
|
+
export { AUDIT_ACTOR_HEADER, type BaseAuditOptions, type CreateAuditMiddlewareOptions, type NextMiddleware, type RouteContext, type WithAuditOptions, type WithAuditRouteOptions, betterAuditNext, createAuditMiddleware, withAudit, withAuditRoute };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { ContextExtractor } from '@usebetterdev/audit-core';
|
|
2
|
+
export { AuditContext, ContextExtractor, ValueExtractor, fromBearerToken, fromCookie, fromHeader, getAuditContext, runWithAuditContext } from '@usebetterdev/audit-core';
|
|
3
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
4
|
+
|
|
5
|
+
type RouteContext = Record<string, unknown> | undefined;
|
|
6
|
+
type NextMiddleware = (request: NextRequest) => Promise<NextResponse>;
|
|
7
|
+
/**
|
|
8
|
+
* Shared options across all audit wrappers.
|
|
9
|
+
*/
|
|
10
|
+
interface BaseAuditOptions {
|
|
11
|
+
/** Context extractor for pulling actor identity from the request. */
|
|
12
|
+
extractor?: ContextExtractor;
|
|
13
|
+
/** Called when an extractor throws. Defaults to silent fail-open. */
|
|
14
|
+
onError?: (error: unknown) => void;
|
|
15
|
+
}
|
|
16
|
+
interface CreateAuditMiddlewareOptions extends BaseAuditOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Request header name used to forward the extracted actor id to downstream
|
|
19
|
+
* route handlers. Defaults to `AUDIT_ACTOR_HEADER`.
|
|
20
|
+
*/
|
|
21
|
+
actorHeader?: string;
|
|
22
|
+
}
|
|
23
|
+
type WithAuditRouteOptions = BaseAuditOptions;
|
|
24
|
+
type WithAuditOptions = BaseAuditOptions;
|
|
25
|
+
/**
|
|
26
|
+
* Default header name used to forward the actor id from middleware to route
|
|
27
|
+
* handlers.
|
|
28
|
+
*
|
|
29
|
+
* Use this constant to keep the middleware and route handler in sync:
|
|
30
|
+
* ```ts
|
|
31
|
+
* // middleware.ts
|
|
32
|
+
* export default createAuditMiddleware(); // sets AUDIT_ACTOR_HEADER
|
|
33
|
+
*
|
|
34
|
+
* // app/api/orders/route.ts
|
|
35
|
+
* export const GET = withAuditRoute(handler, {
|
|
36
|
+
* extractor: { actor: fromHeader(AUDIT_ACTOR_HEADER) },
|
|
37
|
+
* });
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
declare const AUDIT_ACTOR_HEADER = "x-better-audit-actor-id";
|
|
41
|
+
/**
|
|
42
|
+
* Creates a Next.js edge middleware function that extracts audit context from
|
|
43
|
+
* the incoming request and forwards the actor id as a request header to
|
|
44
|
+
* downstream route handlers.
|
|
45
|
+
*
|
|
46
|
+
* **Important:** Next.js middleware runs in a separate Edge Runtime execution
|
|
47
|
+
* context from route handlers — AsyncLocalStorage set here does NOT propagate.
|
|
48
|
+
* This middleware forwards the extracted actor id via a request header
|
|
49
|
+
* (`x-better-audit-actor-id` by default). Use `withAuditRoute` in your route
|
|
50
|
+
* handlers to read that header and populate ALS.
|
|
51
|
+
*
|
|
52
|
+
* The middleware **always** overwrites the actor header on the forwarded
|
|
53
|
+
* request — when extraction fails, it is set to an empty string. This prevents
|
|
54
|
+
* clients from spoofing the actor id by sending the header directly.
|
|
55
|
+
*
|
|
56
|
+
* The middleware is non-blocking: if context extraction fails, the request
|
|
57
|
+
* proceeds with an empty actor header (fail open).
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* // middleware.ts
|
|
62
|
+
* import { createAuditMiddleware } from "@usebetterdev/audit-next";
|
|
63
|
+
* export default createAuditMiddleware();
|
|
64
|
+
* export const config = { matcher: "/api/:path*" };
|
|
65
|
+
*
|
|
66
|
+
* // app/api/orders/route.ts
|
|
67
|
+
* import { withAuditRoute, fromHeader, AUDIT_ACTOR_HEADER } from "@usebetterdev/audit-next";
|
|
68
|
+
* export const GET = withAuditRoute(handler, {
|
|
69
|
+
* extractor: { actor: fromHeader(AUDIT_ACTOR_HEADER) },
|
|
70
|
+
* });
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
declare function createAuditMiddleware(options?: CreateAuditMiddlewareOptions): NextMiddleware;
|
|
74
|
+
/**
|
|
75
|
+
* Convenience alias for `createAuditMiddleware`. Use as the default export
|
|
76
|
+
* in `middleware.ts`.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* // middleware.ts
|
|
81
|
+
* import { betterAuditNext } from "@usebetterdev/audit-next";
|
|
82
|
+
* export default betterAuditNext();
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
declare function betterAuditNext(options?: CreateAuditMiddlewareOptions): NextMiddleware;
|
|
86
|
+
/**
|
|
87
|
+
* Wraps a Next.js App Router route handler with audit context propagation.
|
|
88
|
+
*
|
|
89
|
+
* Extracts the actor identity from the incoming request (by default reads
|
|
90
|
+
* `sub` from `Authorization: Bearer <jwt>`, or reads the
|
|
91
|
+
* `x-better-audit-actor-id` header forwarded by `createAuditMiddleware`) and
|
|
92
|
+
* runs the handler inside an AsyncLocalStorage scope so that `getAuditContext()`
|
|
93
|
+
* returns the correct context within the handler.
|
|
94
|
+
*
|
|
95
|
+
* `NextRequest` is a Web `Request` subclass — extractors from audit-core
|
|
96
|
+
* (`fromBearerToken`, `fromHeader`, `fromCookie`) work directly.
|
|
97
|
+
*
|
|
98
|
+
* The wrapper is non-blocking: if extraction fails, the handler runs without
|
|
99
|
+
* audit context (fail open).
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```ts
|
|
103
|
+
* // app/api/orders/route.ts — standalone (no middleware)
|
|
104
|
+
* import { withAuditRoute } from "@usebetterdev/audit-next";
|
|
105
|
+
* export const GET = withAuditRoute(handler);
|
|
106
|
+
*
|
|
107
|
+
* // app/api/orders/route.ts — paired with createAuditMiddleware
|
|
108
|
+
* import { withAuditRoute, fromHeader, AUDIT_ACTOR_HEADER } from "@usebetterdev/audit-next";
|
|
109
|
+
* export const GET = withAuditRoute(handler, {
|
|
110
|
+
* extractor: { actor: fromHeader(AUDIT_ACTOR_HEADER) },
|
|
111
|
+
* });
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
declare function withAuditRoute<C extends RouteContext = RouteContext>(handler: (request: NextRequest, context: C) => Promise<Response>, options?: WithAuditRouteOptions): (request: NextRequest, context: C) => Promise<Response>;
|
|
115
|
+
/**
|
|
116
|
+
* Wraps a Next.js server action with audit context propagation.
|
|
117
|
+
*
|
|
118
|
+
* Server actions do not receive a `Request` object. This wrapper reads all
|
|
119
|
+
* request headers via `next/headers` and constructs a synthetic `Request` to
|
|
120
|
+
* run the configured extractor against, then wraps the action in an
|
|
121
|
+
* AsyncLocalStorage scope so that `getAuditContext()` returns the correct
|
|
122
|
+
* context within the action.
|
|
123
|
+
*
|
|
124
|
+
* The wrapper is non-blocking: if extraction fails (including when `headers()`
|
|
125
|
+
* is unavailable), the action runs without audit context (fail open).
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* ```ts
|
|
129
|
+
* // app/actions.ts
|
|
130
|
+
* "use server";
|
|
131
|
+
* import { withAudit } from "@usebetterdev/audit-next";
|
|
132
|
+
*
|
|
133
|
+
* export const createOrder = withAudit(async (formData: FormData) => {
|
|
134
|
+
* // getAuditContext() returns the actor here
|
|
135
|
+
* });
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
declare function withAudit<TArgs extends unknown[], TResult>(action: (...args: TArgs) => Promise<TResult>, options?: WithAuditOptions): (...args: TArgs) => Promise<TResult>;
|
|
139
|
+
|
|
140
|
+
export { AUDIT_ACTOR_HEADER, type BaseAuditOptions, type CreateAuditMiddlewareOptions, type NextMiddleware, type RouteContext, type WithAuditOptions, type WithAuditRouteOptions, betterAuditNext, createAuditMiddleware, withAudit, withAuditRoute };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import {
|
|
3
|
+
fromBearerToken,
|
|
4
|
+
runWithAuditContext
|
|
5
|
+
} from "@usebetterdev/audit-core";
|
|
6
|
+
import { headers } from "next/headers";
|
|
7
|
+
import { NextResponse } from "next/server";
|
|
8
|
+
import { fromBearerToken as fromBearerToken2, fromCookie, fromHeader, getAuditContext as getAuditContext2, runWithAuditContext as runWithAuditContext2 } from "@usebetterdev/audit-core";
|
|
9
|
+
var AUDIT_ACTOR_HEADER = "x-better-audit-actor-id";
|
|
10
|
+
var defaultExtractor = {
|
|
11
|
+
actor: fromBearerToken("sub")
|
|
12
|
+
};
|
|
13
|
+
async function tryExtract(extractor, request, onError) {
|
|
14
|
+
if (!extractor) {
|
|
15
|
+
return void 0;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
return await extractor(request);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (onError) {
|
|
21
|
+
onError(error);
|
|
22
|
+
}
|
|
23
|
+
return void 0;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function createAuditMiddleware(options = {}) {
|
|
27
|
+
const extractor = options.extractor ?? defaultExtractor;
|
|
28
|
+
const actorHeader = options.actorHeader ?? AUDIT_ACTOR_HEADER;
|
|
29
|
+
return async (request) => {
|
|
30
|
+
const actorId = await tryExtract(
|
|
31
|
+
extractor.actor,
|
|
32
|
+
request,
|
|
33
|
+
options.onError
|
|
34
|
+
);
|
|
35
|
+
const requestHeaders = new Headers(request.headers);
|
|
36
|
+
requestHeaders.set(actorHeader, actorId ?? "");
|
|
37
|
+
return NextResponse.next({
|
|
38
|
+
request: { headers: requestHeaders }
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function betterAuditNext(options = {}) {
|
|
43
|
+
return createAuditMiddleware(options);
|
|
44
|
+
}
|
|
45
|
+
function withAuditRoute(handler, options = {}) {
|
|
46
|
+
const extractor = options.extractor ?? defaultExtractor;
|
|
47
|
+
return async (request, context) => {
|
|
48
|
+
const actorId = await tryExtract(
|
|
49
|
+
extractor.actor,
|
|
50
|
+
request,
|
|
51
|
+
options.onError
|
|
52
|
+
);
|
|
53
|
+
if (actorId === void 0) {
|
|
54
|
+
return handler(request, context);
|
|
55
|
+
}
|
|
56
|
+
const auditContext = { actorId };
|
|
57
|
+
return runWithAuditContext(auditContext, () => handler(request, context));
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function withAudit(action, options = {}) {
|
|
61
|
+
const extractor = options.extractor ?? defaultExtractor;
|
|
62
|
+
return async (...args) => {
|
|
63
|
+
let actorId;
|
|
64
|
+
try {
|
|
65
|
+
const headersList = await headers();
|
|
66
|
+
const syntheticRequest = new Request("http://localhost", {
|
|
67
|
+
headers: headersList
|
|
68
|
+
});
|
|
69
|
+
actorId = await tryExtract(
|
|
70
|
+
extractor.actor,
|
|
71
|
+
syntheticRequest,
|
|
72
|
+
options.onError
|
|
73
|
+
);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
options.onError?.(error);
|
|
76
|
+
}
|
|
77
|
+
if (actorId === void 0) {
|
|
78
|
+
return action(...args);
|
|
79
|
+
}
|
|
80
|
+
const auditContext = { actorId };
|
|
81
|
+
return runWithAuditContext(auditContext, () => action(...args));
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export {
|
|
85
|
+
AUDIT_ACTOR_HEADER,
|
|
86
|
+
betterAuditNext,
|
|
87
|
+
createAuditMiddleware,
|
|
88
|
+
fromBearerToken2 as fromBearerToken,
|
|
89
|
+
fromCookie,
|
|
90
|
+
fromHeader,
|
|
91
|
+
getAuditContext2 as getAuditContext,
|
|
92
|
+
runWithAuditContext2 as runWithAuditContext,
|
|
93
|
+
withAudit,
|
|
94
|
+
withAuditRoute
|
|
95
|
+
};
|
|
96
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["import {\n fromBearerToken,\n getAuditContext,\n runWithAuditContext,\n} from \"@usebetterdev/audit-core\";\nimport type {\n AuditContext,\n ContextExtractor,\n ValueExtractor,\n} from \"@usebetterdev/audit-core\";\nimport { headers } from \"next/headers\";\nimport { NextResponse } from \"next/server\";\nimport type { NextRequest } from \"next/server\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport type RouteContext = Record<string, unknown> | undefined;\n\nexport type NextMiddleware = (request: NextRequest) => Promise<NextResponse>;\n\n/**\n * Shared options across all audit wrappers.\n */\nexport interface BaseAuditOptions {\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\nexport interface CreateAuditMiddlewareOptions extends BaseAuditOptions {\n /**\n * Request header name used to forward the extracted actor id to downstream\n * route handlers. Defaults to `AUDIT_ACTOR_HEADER`.\n */\n actorHeader?: string;\n}\n\nexport type WithAuditRouteOptions = BaseAuditOptions;\n\nexport type WithAuditOptions = BaseAuditOptions;\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/**\n * Default header name used to forward the actor id from middleware to route\n * handlers.\n *\n * Use this constant to keep the middleware and route handler in sync:\n * ```ts\n * // middleware.ts\n * export default createAuditMiddleware(); // sets AUDIT_ACTOR_HEADER\n *\n * // app/api/orders/route.ts\n * export const GET = withAuditRoute(handler, {\n * extractor: { actor: fromHeader(AUDIT_ACTOR_HEADER) },\n * });\n * ```\n */\nexport const AUDIT_ACTOR_HEADER = \"x-better-audit-actor-id\";\n\n// ---------------------------------------------------------------------------\n// Private helpers\n// ---------------------------------------------------------------------------\n\n/** Default extractor: reads `sub` from `Authorization: Bearer <jwt>` as actor. */\nconst defaultExtractor: ContextExtractor = {\n actor: fromBearerToken(\"sub\"),\n};\n\n/**\n * Runs a value extractor safely, returning undefined on error (fail open).\n */\nasync function tryExtract(\n extractor: ValueExtractor | undefined,\n request: Request,\n onError: ((error: unknown) => void) | undefined,\n): Promise<string | undefined> {\n if (!extractor) {\n return undefined;\n }\n try {\n return await extractor(request);\n } catch (error: unknown) {\n if (onError) {\n onError(error);\n }\n return undefined;\n }\n}\n\n// ---------------------------------------------------------------------------\n// createAuditMiddleware / betterAuditNext\n// ---------------------------------------------------------------------------\n\n/**\n * Creates a Next.js edge middleware function that extracts audit context from\n * the incoming request and forwards the actor id as a request header to\n * downstream route handlers.\n *\n * **Important:** Next.js middleware runs in a separate Edge Runtime execution\n * context from route handlers — AsyncLocalStorage set here does NOT propagate.\n * This middleware forwards the extracted actor id via a request header\n * (`x-better-audit-actor-id` by default). Use `withAuditRoute` in your route\n * handlers to read that header and populate ALS.\n *\n * The middleware **always** overwrites the actor header on the forwarded\n * request — when extraction fails, it is set to an empty string. This prevents\n * clients from spoofing the actor id by sending the header directly.\n *\n * The middleware is non-blocking: if context extraction fails, the request\n * proceeds with an empty actor header (fail open).\n *\n * @example\n * ```ts\n * // middleware.ts\n * import { createAuditMiddleware } from \"@usebetterdev/audit-next\";\n * export default createAuditMiddleware();\n * export const config = { matcher: \"/api/:path*\" };\n *\n * // app/api/orders/route.ts\n * import { withAuditRoute, fromHeader, AUDIT_ACTOR_HEADER } from \"@usebetterdev/audit-next\";\n * export const GET = withAuditRoute(handler, {\n * extractor: { actor: fromHeader(AUDIT_ACTOR_HEADER) },\n * });\n * ```\n */\nexport function createAuditMiddleware(\n options: CreateAuditMiddlewareOptions = {},\n): NextMiddleware {\n const extractor = options.extractor ?? defaultExtractor;\n const actorHeader = options.actorHeader ?? AUDIT_ACTOR_HEADER;\n\n return async (request) => {\n const actorId = await tryExtract(\n extractor.actor,\n request,\n options.onError,\n );\n\n // Always forward with an explicit header value so clients cannot spoof\n // the actor id by sending the header directly. When extraction fails the\n // header is set to \"\" — fromHeader() treats empty values as undefined.\n const requestHeaders = new Headers(request.headers);\n requestHeaders.set(actorHeader, actorId ?? \"\");\n\n return NextResponse.next({\n request: { headers: requestHeaders },\n });\n };\n}\n\n/**\n * Convenience alias for `createAuditMiddleware`. Use as the default export\n * in `middleware.ts`.\n *\n * @example\n * ```ts\n * // middleware.ts\n * import { betterAuditNext } from \"@usebetterdev/audit-next\";\n * export default betterAuditNext();\n * ```\n */\nexport function betterAuditNext(\n options: CreateAuditMiddlewareOptions = {},\n): NextMiddleware {\n return createAuditMiddleware(options);\n}\n\n// ---------------------------------------------------------------------------\n// withAuditRoute\n// ---------------------------------------------------------------------------\n\n/**\n * Wraps a Next.js App Router route handler with audit context propagation.\n *\n * Extracts the actor identity from the incoming request (by default reads\n * `sub` from `Authorization: Bearer <jwt>`, or reads the\n * `x-better-audit-actor-id` header forwarded by `createAuditMiddleware`) and\n * runs the handler inside an AsyncLocalStorage scope so that `getAuditContext()`\n * returns the correct context within the handler.\n *\n * `NextRequest` is a Web `Request` subclass — extractors from audit-core\n * (`fromBearerToken`, `fromHeader`, `fromCookie`) work directly.\n *\n * The wrapper is non-blocking: if extraction fails, the handler runs without\n * audit context (fail open).\n *\n * @example\n * ```ts\n * // app/api/orders/route.ts — standalone (no middleware)\n * import { withAuditRoute } from \"@usebetterdev/audit-next\";\n * export const GET = withAuditRoute(handler);\n *\n * // app/api/orders/route.ts — paired with createAuditMiddleware\n * import { withAuditRoute, fromHeader, AUDIT_ACTOR_HEADER } from \"@usebetterdev/audit-next\";\n * export const GET = withAuditRoute(handler, {\n * extractor: { actor: fromHeader(AUDIT_ACTOR_HEADER) },\n * });\n * ```\n */\nexport function withAuditRoute<C extends RouteContext = RouteContext>(\n handler: (request: NextRequest, context: C) => Promise<Response>,\n options: WithAuditRouteOptions = {},\n): (request: NextRequest, context: C) => Promise<Response> {\n const extractor = options.extractor ?? defaultExtractor;\n\n return async (request, context) => {\n const actorId = await tryExtract(\n extractor.actor,\n request,\n options.onError,\n );\n\n if (actorId === undefined) {\n return handler(request, context);\n }\n\n const auditContext: AuditContext = { actorId };\n\n return runWithAuditContext(auditContext, () => handler(request, context));\n };\n}\n\n// ---------------------------------------------------------------------------\n// withAudit (server actions)\n// ---------------------------------------------------------------------------\n\n/**\n * Wraps a Next.js server action with audit context propagation.\n *\n * Server actions do not receive a `Request` object. This wrapper reads all\n * request headers via `next/headers` and constructs a synthetic `Request` to\n * run the configured extractor against, then wraps the action in an\n * AsyncLocalStorage scope so that `getAuditContext()` returns the correct\n * context within the action.\n *\n * The wrapper is non-blocking: if extraction fails (including when `headers()`\n * is unavailable), the action runs without audit context (fail open).\n *\n * @example\n * ```ts\n * // app/actions.ts\n * \"use server\";\n * import { withAudit } from \"@usebetterdev/audit-next\";\n *\n * export const createOrder = withAudit(async (formData: FormData) => {\n * // getAuditContext() returns the actor here\n * });\n * ```\n */\nexport function withAudit<TArgs extends unknown[], TResult>(\n action: (...args: TArgs) => Promise<TResult>,\n options: WithAuditOptions = {},\n): (...args: TArgs) => Promise<TResult> {\n const extractor = options.extractor ?? defaultExtractor;\n\n return async (...args) => {\n let actorId: string | undefined;\n\n try {\n const headersList = await headers();\n const syntheticRequest = new Request(\"http://localhost\", {\n headers: headersList,\n });\n actorId = await tryExtract(\n extractor.actor,\n syntheticRequest,\n options.onError,\n );\n } catch (error: unknown) {\n // headers() throws outside Next.js request context — fail open\n options.onError?.(error);\n }\n\n if (actorId === undefined) {\n return action(...args);\n }\n\n const auditContext: AuditContext = { actorId };\n\n return runWithAuditContext(auditContext, () => action(...args));\n };\n}\n\n// ---------------------------------------------------------------------------\n// Re-exports\n// ---------------------------------------------------------------------------\n\nexport type { AuditContext, ContextExtractor, ValueExtractor };\nexport { fromBearerToken, fromCookie, fromHeader, getAuditContext, runWithAuditContext } from \"@usebetterdev/audit-core\";\n"],"mappings":";AAAA;AAAA,EACE;AAAA,EAEA;AAAA,OACK;AAMP,SAAS,eAAe;AACxB,SAAS,oBAAoB;AA2R7B,SAAS,mBAAAA,kBAAiB,YAAY,YAAY,mBAAAC,kBAAiB,uBAAAC,4BAA2B;AAvOvF,IAAM,qBAAqB;AAOlC,IAAM,mBAAqC;AAAA,EACzC,OAAO,gBAAgB,KAAK;AAC9B;AAKA,eAAe,WACb,WACA,SACA,SAC6B;AAC7B,MAAI,CAAC,WAAW;AACd,WAAO;AAAA,EACT;AACA,MAAI;AACF,WAAO,MAAM,UAAU,OAAO;AAAA,EAChC,SAAS,OAAgB;AACvB,QAAI,SAAS;AACX,cAAQ,KAAK;AAAA,IACf;AACA,WAAO;AAAA,EACT;AACF;AAsCO,SAAS,sBACd,UAAwC,CAAC,GACzB;AAChB,QAAM,YAAY,QAAQ,aAAa;AACvC,QAAM,cAAc,QAAQ,eAAe;AAE3C,SAAO,OAAO,YAAY;AACxB,UAAM,UAAU,MAAM;AAAA,MACpB,UAAU;AAAA,MACV;AAAA,MACA,QAAQ;AAAA,IACV;AAKA,UAAM,iBAAiB,IAAI,QAAQ,QAAQ,OAAO;AAClD,mBAAe,IAAI,aAAa,WAAW,EAAE;AAE7C,WAAO,aAAa,KAAK;AAAA,MACvB,SAAS,EAAE,SAAS,eAAe;AAAA,IACrC,CAAC;AAAA,EACH;AACF;AAaO,SAAS,gBACd,UAAwC,CAAC,GACzB;AAChB,SAAO,sBAAsB,OAAO;AACtC;AAkCO,SAAS,eACd,SACA,UAAiC,CAAC,GACuB;AACzD,QAAM,YAAY,QAAQ,aAAa;AAEvC,SAAO,OAAO,SAAS,YAAY;AACjC,UAAM,UAAU,MAAM;AAAA,MACpB,UAAU;AAAA,MACV;AAAA,MACA,QAAQ;AAAA,IACV;AAEA,QAAI,YAAY,QAAW;AACzB,aAAO,QAAQ,SAAS,OAAO;AAAA,IACjC;AAEA,UAAM,eAA6B,EAAE,QAAQ;AAE7C,WAAO,oBAAoB,cAAc,MAAM,QAAQ,SAAS,OAAO,CAAC;AAAA,EAC1E;AACF;AA6BO,SAAS,UACd,QACA,UAA4B,CAAC,GACS;AACtC,QAAM,YAAY,QAAQ,aAAa;AAEvC,SAAO,UAAU,SAAS;AACxB,QAAI;AAEJ,QAAI;AACF,YAAM,cAAc,MAAM,QAAQ;AAClC,YAAM,mBAAmB,IAAI,QAAQ,oBAAoB;AAAA,QACvD,SAAS;AAAA,MACX,CAAC;AACD,gBAAU,MAAM;AAAA,QACd,UAAU;AAAA,QACV;AAAA,QACA,QAAQ;AAAA,MACV;AAAA,IACF,SAAS,OAAgB;AAEvB,cAAQ,UAAU,KAAK;AAAA,IACzB;AAEA,QAAI,YAAY,QAAW;AACzB,aAAO,OAAO,GAAG,IAAI;AAAA,IACvB;AAEA,UAAM,eAA6B,EAAE,QAAQ;AAE7C,WAAO,oBAAoB,cAAc,MAAM,OAAO,GAAG,IAAI,CAAC;AAAA,EAChE;AACF;","names":["fromBearerToken","getAuditContext","runWithAuditContext"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@usebetterdev/audit-next",
|
|
3
|
+
"version": "0.6.0",
|
|
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
|
+
"build:types": "tsc --build tsconfig.build.json",
|
|
29
|
+
"lint": "oxlint",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"typecheck": "tsc --noEmit"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@usebetterdev/audit-core": "workspace:*"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"next": ">=14.0.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^22.10.0",
|
|
41
|
+
"next": "^15.5.2",
|
|
42
|
+
"tsup": "^8.3.5",
|
|
43
|
+
"typescript": "~5.7.2",
|
|
44
|
+
"vitest": "^2.1.6"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=22"
|
|
48
|
+
}
|
|
49
|
+
}
|