@remit/backend 0.0.121 → 0.0.122

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.121",
3
+ "version": "0.0.122",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Issue #1054: the backend must log one structured access line per request
3
+ * (method, path, status, duration, correlation id) at a level that is on in
4
+ * production. The access log lives in `rawHandler` in `src/index.ts`, wrapping
5
+ * the authenticated request path — so a 401 denial from edge-auth also gets an
6
+ * access line (once it flows through `formatResponse` with the correlation id).
7
+ *
8
+ * The 401 response shape (correlation id header + CORS on the denial) is tested
9
+ * directly in `jwt-auth.test.ts` via `runWithRequestContext`; this file tests
10
+ * that the handler emits the access log line on every request by spying on the
11
+ * logger's info method.
12
+ */
13
+ import assert from "node:assert/strict";
14
+ import { describe, it, mock } from "node:test";
15
+ import { logger } from "@remit/logger-lambda";
16
+ import type { APIGatewayProxyEvent, Context } from "aws-lambda";
17
+ import { handler } from "./index.js";
18
+
19
+ const buildEvent = (
20
+ overrides: Partial<APIGatewayProxyEvent> = {},
21
+ ): APIGatewayProxyEvent =>
22
+ ({
23
+ path: "/api/test",
24
+ httpMethod: "GET",
25
+ pathParameters: {},
26
+ queryStringParameters: {},
27
+ headers: {},
28
+ requestContext: {},
29
+ ...overrides,
30
+ }) as unknown as APIGatewayProxyEvent;
31
+
32
+ const buildContext = (correlationId = "ctx-test-1"): Context =>
33
+ ({
34
+ awsRequestId: correlationId,
35
+ functionName: "test-function",
36
+ getRemainingTimeInMillis: () => 30000,
37
+ }) as unknown as Context;
38
+
39
+ describe("issue #1054: per-request access log", () => {
40
+ it('emits one "Request completed" line at info level with method, path, status, duration and correlation id', async () => {
41
+ delete process.env.LOCAL_ACCOUNT_CONFIG_ID;
42
+
43
+ // Spy on logger.info — the real logger passes (fields, message) in
44
+ // pino order, so the spy captures the structured fields and message.
45
+ const infoSpy = mock.method(logger, "info");
46
+
47
+ try {
48
+ await handler(
49
+ buildEvent({ path: "/api/__nonexistent_for_test__" }),
50
+ buildContext("ctx-access-log"),
51
+ ).catch(() => {
52
+ // 404s are expected; we only care about the log line.
53
+ });
54
+
55
+ // `logger.info` is overloaded ((obj, msg?) and (msg, obj?)), so the
56
+ // mock types `call.arguments` by a single overload. Read the
57
+ // arguments positionally through an unknown[] view instead.
58
+ const accessCalls = infoSpy.mock.calls.filter((call) => {
59
+ // pino calls logger.info(fields, message)
60
+ const args = call.arguments as unknown[];
61
+ return args[1] === "Request completed";
62
+ });
63
+
64
+ assert.ok(accessCalls.length >= 1, "expected an access log line");
65
+ const fields = (accessCalls[0].arguments as unknown[])[0] as Record<
66
+ string,
67
+ unknown
68
+ >;
69
+
70
+ // infoSpy was called on logger.info, so the level is "info" by
71
+ // construction — pino's adapter routes .info() calls to the info
72
+ // level. No separate level field needed.
73
+
74
+ assert.equal(fields.method, "GET");
75
+ assert.equal(fields.path, "/api/__nonexistent_for_test__");
76
+ assert.equal(fields.correlationId, "ctx-access-log");
77
+ assert.equal(typeof fields.statusCode, "number");
78
+ assert.equal(typeof fields.durationMs, "number");
79
+ } finally {
80
+ infoSpy.mock.restore();
81
+ }
82
+ });
83
+ });
package/src/index.ts CHANGED
@@ -180,9 +180,17 @@ const rawHandler = async (event: APIGatewayProxyEvent, context: Context) => {
180
180
  logger.debug({ method: event.httpMethod, path }, "Request received");
181
181
 
182
182
  const origin = readOriginHeader(event.headers);
183
-
184
- return runWithRequestContext(
185
- { origin, correlationId: context.awsRequestId },
183
+ const startedAt = Date.now();
184
+ // Capture the correlation id here: it is seeded by
185
+ // runWithRequestContext below (from context.awsRequestId), but that
186
+ // AsyncLocalStorage scope does not reliably survive the
187
+ // openapi-backend handleRequest call chain, so we read it from
188
+ // `context.awsRequestId` directly rather than via
189
+ // getRequestCorrelationId at log time.
190
+ const correlationId = context.awsRequestId;
191
+
192
+ const result = await runWithRequestContext(
193
+ { origin, correlationId },
186
194
  async () => {
187
195
  if (usesBetterAuthJwt()) {
188
196
  const denied = await authenticateSelfHostRequest(event);
@@ -194,6 +202,25 @@ const rawHandler = async (event: APIGatewayProxyEvent, context: Context) => {
194
202
  .catch(handleError);
195
203
  },
196
204
  );
205
+
206
+ // One structured access line per request: method, path, status, and
207
+ // duration. The correlation id is inlined as a top-level field so a bug
208
+ // report quoting the id can grep logs by it directly, and emitted at
209
+ // info so it is on by default in production (the debug "Request
210
+ // received" above already filters out under normal log levels).
211
+ // biome-ignore lint/plugin/no-logger-info: one access line per request is an audit-grade signal (issue #1054)
212
+ logger.info(
213
+ {
214
+ method: event.httpMethod,
215
+ path,
216
+ statusCode: result.statusCode,
217
+ durationMs: Date.now() - startedAt,
218
+ correlationId,
219
+ },
220
+ "Request completed",
221
+ );
222
+
223
+ return result;
197
224
  },
198
225
  );
199
226
  };
@@ -5,6 +5,7 @@ import {
5
5
  _setVerifierForTest,
6
6
  authenticateSelfHostRequest,
7
7
  } from "./jwt-auth.js";
8
+ import { runWithRequestContext } from "./request-context.js";
8
9
 
9
10
  const buildEvent = (
10
11
  overrides: Partial<APIGatewayProxyEvent> = {},
@@ -41,7 +42,7 @@ test("valid token injects verified sub into authorizer claims", async () => {
41
42
  assert.equal(event.requestContext.authorizer?.claims?.email, "a@b.com");
42
43
  });
43
44
 
44
- test("invalid token returns 401 and does not inject claims", async () => {
45
+ test("invalid token returns 401 with correlation id and CORS headers", async () => {
45
46
  _setVerifierForTest(async () => {
46
47
  throw new Error("bad signature");
47
48
  });
@@ -49,9 +50,29 @@ test("invalid token returns 401 and does not inject claims", async () => {
49
50
  headers: { authorization: "Bearer bad.token" },
50
51
  });
51
52
 
52
- const result = await authenticateSelfHostRequest(event);
53
+ const result = await runWithRequestContext(
54
+ { correlationId: "req-invalid-token" },
55
+ async () => authenticateSelfHostRequest(event),
56
+ );
53
57
 
54
58
  assert.equal(result?.statusCode, 401);
59
+ assert.equal(
60
+ result?.headers?.["x-correlation-id"],
61
+ "req-invalid-token",
62
+ "edge-auth 401 must carry the correlation id",
63
+ );
64
+ assert.equal(
65
+ result?.headers?.["Access-Control-Allow-Origin"],
66
+ "*",
67
+ "edge-auth 401 must carry CORS headers",
68
+ );
69
+ assert.equal(
70
+ result?.body,
71
+ JSON.stringify({
72
+ code: "unauthorized",
73
+ message: "Invalid or expired token",
74
+ }),
75
+ );
55
76
  assert.equal(event.requestContext.authorizer, undefined);
56
77
  });
57
78
 
@@ -64,12 +85,25 @@ test("no token with a local bypass configured is allowed", async () => {
64
85
  assert.equal(result, null);
65
86
  });
66
87
 
67
- test("no token and no bypass returns 401", async () => {
88
+ test("no token and no bypass returns 401 with correlation id", async () => {
68
89
  const event = buildEvent();
69
90
 
70
- const result = await authenticateSelfHostRequest(event);
91
+ const result = await runWithRequestContext(
92
+ { correlationId: "req-no-token" },
93
+ async () => authenticateSelfHostRequest(event),
94
+ );
71
95
 
72
96
  assert.equal(result?.statusCode, 401);
97
+ assert.equal(
98
+ result?.headers?.["x-correlation-id"],
99
+ "req-no-token",
100
+ "edge-auth 401 must carry the correlation id",
101
+ );
102
+ assert.equal(
103
+ result?.headers?.["Access-Control-Allow-Origin"],
104
+ "*",
105
+ "edge-auth 401 must carry CORS headers",
106
+ );
73
107
  });
74
108
 
75
109
  test("a tokenless GET to the Microsoft OAuth callback is admitted", async () => {
package/src/jwt-auth.ts CHANGED
@@ -8,6 +8,7 @@ import { readCalendarFeedToken } from "@remit/calendar-service";
8
8
  import { logger } from "@remit/logger-lambda";
9
9
  import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
10
10
  import { defaultErrorCode } from "./error.js";
11
+ import { formatResponse } from "./response.js";
11
12
 
12
13
  let verifier: JwtVerifier | null = null;
13
14
 
@@ -31,11 +32,8 @@ const readHeader = (
31
32
  return undefined;
32
33
  };
33
34
 
34
- const unauthorized = (message: string): APIGatewayProxyResult => ({
35
- statusCode: 401,
36
- headers: { "Content-Type": "application/json" },
37
- body: JSON.stringify({ code: defaultErrorCode(401), message }),
38
- });
35
+ const unauthorized = (message: string): APIGatewayProxyResult =>
36
+ formatResponse({ code: defaultErrorCode(401), message }, 401);
39
37
 
40
38
  const injectClaims = (
41
39
  event: APIGatewayProxyEvent,