@remit/backend 0.0.121 → 0.0.123
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 +1 -1
- package/scripts/backfill-classification.ts +89 -0
- package/src/index.test.ts +83 -0
- package/src/index.ts +30 -3
- package/src/jwt-auth.test.ts +38 -4
- package/src/jwt-auth.ts +3 -5
package/package.json
CHANGED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { logger } from "@remit/logger-lambda";
|
|
5
|
+
import {
|
|
6
|
+
backfillClassifications,
|
|
7
|
+
type ClassificationBackfillCheckpoint,
|
|
8
|
+
type ClassificationBackfillCheckpointStore,
|
|
9
|
+
} from "@remit/mailbox-service";
|
|
10
|
+
import { getClient } from "../src/service/data-client.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One-time, full-corpus classification backfill over the stored-body cohort
|
|
14
|
+
* the classifier never examined (issue #1197): rows whose bodies were synced
|
|
15
|
+
* before the classifier reached them, which carry `NotExamined` on
|
|
16
|
+
* `Message.classificationState` and so stay `uncategorized` in the list. The
|
|
17
|
+
* same alternate-entrypoint shape `backfill-list-id.mjs` uses: baked into the
|
|
18
|
+
* backend image, never wired into a compose one-shot, run by hand once per
|
|
19
|
+
* install:
|
|
20
|
+
*
|
|
21
|
+
* docker compose -f docker-compose.sqlite.yml run --rm backend \
|
|
22
|
+
* node backfill-classification.mjs
|
|
23
|
+
*
|
|
24
|
+
* Safe to interrupt: progress is checkpointed to disk after every page and
|
|
25
|
+
* picked back up on the next run, every already-examined row reads as
|
|
26
|
+
* `alreadyExamined` on a rerun, and a row whose category was already decided
|
|
27
|
+
* is recorded without re-deriving it, so a partial pass resumes rather than
|
|
28
|
+
* repeats.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const CHECKPOINT_PATH =
|
|
32
|
+
process.env.CLASSIFICATION_BACKFILL_CHECKPOINT_PATH ??
|
|
33
|
+
"/data/sqlite/classification-backfill-checkpoint.json";
|
|
34
|
+
|
|
35
|
+
const isCheckpoint = (
|
|
36
|
+
value: unknown,
|
|
37
|
+
): value is ClassificationBackfillCheckpoint => {
|
|
38
|
+
if (typeof value !== "object" || value === null) return false;
|
|
39
|
+
if (!("accountConfigId" in value)) return false;
|
|
40
|
+
const candidate = value as Record<string, unknown>;
|
|
41
|
+
return (
|
|
42
|
+
typeof candidate.accountConfigId === "string" &&
|
|
43
|
+
(candidate.continuationToken === undefined ||
|
|
44
|
+
typeof candidate.continuationToken === "string")
|
|
45
|
+
);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const fileCheckpointStore: ClassificationBackfillCheckpointStore = {
|
|
49
|
+
load: async () => {
|
|
50
|
+
if (!existsSync(CHECKPOINT_PATH)) return undefined;
|
|
51
|
+
const raw = await readFile(CHECKPOINT_PATH, "utf8");
|
|
52
|
+
const parsed: unknown = JSON.parse(raw);
|
|
53
|
+
return isCheckpoint(parsed) ? parsed : undefined;
|
|
54
|
+
},
|
|
55
|
+
save: async (checkpoint) => {
|
|
56
|
+
await mkdir(dirname(CHECKPOINT_PATH), { recursive: true });
|
|
57
|
+
const staging = `${CHECKPOINT_PATH}.writing`;
|
|
58
|
+
await writeFile(staging, JSON.stringify(checkpoint));
|
|
59
|
+
await rename(staging, CHECKPOINT_PATH);
|
|
60
|
+
},
|
|
61
|
+
clear: async () => {
|
|
62
|
+
await rm(CHECKPOINT_PATH, { force: true });
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const run = async (): Promise<void> => {
|
|
67
|
+
const client = await getClient();
|
|
68
|
+
|
|
69
|
+
const result = await backfillClassifications(
|
|
70
|
+
{
|
|
71
|
+
accountConfigService: client.accountConfig,
|
|
72
|
+
addressService: client.address,
|
|
73
|
+
threadMessageService: client.threadMessage,
|
|
74
|
+
messageService: client.message,
|
|
75
|
+
storageService: client.storage,
|
|
76
|
+
},
|
|
77
|
+
{ checkpointStore: fileCheckpointStore, logger },
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// biome-ignore lint/plugin/no-logger-info: a completed full-corpus backfill is an audit-grade signal
|
|
81
|
+
logger.info({ result }, "backfill done");
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
run()
|
|
85
|
+
.then(() => process.exit(0))
|
|
86
|
+
.catch((error: unknown) => {
|
|
87
|
+
logger.error({ error }, "backfill failed");
|
|
88
|
+
process.exit(1);
|
|
89
|
+
});
|
|
@@ -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
|
-
|
|
185
|
-
|
|
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
|
};
|
package/src/jwt-auth.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|