@remit/backend 0.0.116 → 0.0.117

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.116",
3
+ "version": "0.0.117",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
package/src/error.test.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
- import type { FolderRoleConflict } from "@remit/api-openapi-types";
3
+ import type { ApiError, FolderRoleConflict } from "@remit/api-openapi-types";
4
4
  import {
5
+ BadRequestError,
5
6
  ClientError,
7
+ ConflictError,
6
8
  FolderRoleUnresolvedError,
7
9
  ForbiddenError,
10
+ NotFoundError,
8
11
  UnhandledError,
12
+ UnrecoverableBodyError,
9
13
  } from "@remit/data-ports/errors";
10
14
  import { NO_TRASH_FOLDER_REASON } from "@remit/data-ports/folder-role";
11
15
  import { CanonicalMailboxRole } from "@remit/domain-enums";
@@ -15,6 +19,72 @@ import { handleError } from "./error.js";
15
19
  const parseBody = (body: string): Record<string, unknown> =>
16
20
  JSON.parse(body) as Record<string, unknown>;
17
21
 
22
+ // One shape for every error the API answers with (issue #371): flat, always a
23
+ // `code`, always a `message`. A status class that reaches a client without a
24
+ // code leaves it branching on prose.
25
+ describe("the wire shape of a refusal, per status class", () => {
26
+ const cases: ReadonlyArray<[string, Error, number, string]> = [
27
+ [
28
+ "400",
29
+ new BadRequestError("confirmEmail is required"),
30
+ 400,
31
+ "invalid_request",
32
+ ],
33
+ ["401", new ClientError("Session expired"), 401, "unauthorized"],
34
+ ["403", new ForbiddenError("Not your account"), 403, "forbidden"],
35
+ ["404", new NotFoundError("Mailbox not found: mb-1"), 404, "not_found"],
36
+ [
37
+ "409",
38
+ new ConflictError("An update is already in progress."),
39
+ 409,
40
+ "conflict",
41
+ ],
42
+ [
43
+ "422",
44
+ new UnrecoverableBodyError("This message's body could not be read."),
45
+ 422,
46
+ "unprocessable_entity",
47
+ ],
48
+ ];
49
+
50
+ for (const [name, error, statusCode, code] of cases) {
51
+ it(`answers ${name} with a flat code and message`, async () => {
52
+ const response = await handleError(error);
53
+
54
+ assert.equal(response.statusCode, statusCode);
55
+ assert.deepEqual(parseBody(response.body), {
56
+ code,
57
+ message: error.message,
58
+ });
59
+ });
60
+ }
61
+
62
+ // A 5xx is the one class whose message is not the thrower's. Those sentences
63
+ // are written for a log line and name hosts, queries and ids; the correlation
64
+ // id on the response is how the real one is found.
65
+ it("answers 500 with one code and one sentence, never the thrower's", async () => {
66
+ const response = await handleError(
67
+ new UnhandledError(
68
+ "pg://reader@db-3.internal:5432 timed out on SELECT * FROM message",
69
+ ),
70
+ );
71
+
72
+ assert.equal(response.statusCode, 500);
73
+ assert.deepEqual(parseBody(response.body), {
74
+ code: "internal_error",
75
+ message: "Internal server error",
76
+ });
77
+ });
78
+
79
+ it("types every one of those bodies as the declared ApiError", async () => {
80
+ const response = await handleError(new NotFoundError("Gone"));
81
+ const body: ApiError = JSON.parse(response.body);
82
+
83
+ assert.equal(body.code, "not_found");
84
+ assert.equal(body.message, "Gone");
85
+ });
86
+ });
87
+
18
88
  describe("handleError coded refusals", () => {
19
89
  it("puts the code and every detail of a folder-role 409 on the wire", async () => {
20
90
  const response = await handleError(
@@ -45,18 +115,19 @@ describe("handleError coded refusals", () => {
45
115
  });
46
116
  });
47
117
 
48
- it("keeps an unhandled 500 to a message — no stack, no cause, no code", async () => {
118
+ it("keeps an unhandled 500 to a code and a message — no stack, no cause, no details", async () => {
49
119
  const response = await handleError(
50
120
  new UnhandledError("Something went wrong", new Error("connection reset")),
51
121
  );
52
122
 
53
123
  assert.equal(response.statusCode, 500);
54
124
  assert.deepEqual(parseBody(response.body), {
55
- message: "Something went wrong",
125
+ code: "internal_error",
126
+ message: "Internal server error",
56
127
  });
57
128
  });
58
129
 
59
- it("strips a code a 5xx should never have carried", async () => {
130
+ it("replaces a code a 5xx should never have carried, and drops its details", async () => {
60
131
  const error = new UnhandledError("Something went wrong");
61
132
  error.publicApiError = {
62
133
  code: "folder_role_unresolved",
@@ -67,22 +138,38 @@ describe("handleError coded refusals", () => {
67
138
 
68
139
  assert.equal(response.statusCode, 500);
69
140
  assert.deepEqual(parseBody(response.body), {
70
- message: "Something went wrong",
141
+ code: "internal_error",
142
+ message: "Internal server error",
71
143
  });
72
144
  });
73
145
 
74
- it("answers an unauthenticated request with a 401 and an error body", async () => {
75
- const response = await handleError(new ClientError("Session expired"));
146
+ it("keeps an infrastructure failure to the generic message and code", async () => {
147
+ const error = Object.assign(
148
+ new Error("aws-error: ProvisionedThroughputExceeded on remit-main"),
149
+ { name: "ElectroError" },
150
+ );
76
151
 
77
- assert.equal(response.statusCode, 401);
78
- assert.deepEqual(parseBody(response.body), { message: "Session expired" });
152
+ const response = await handleError(error);
153
+
154
+ assert.equal(response.statusCode, 500);
155
+ assert.deepEqual(parseBody(response.body), {
156
+ code: "internal_error",
157
+ message: "Database temporarily unavailable",
158
+ });
79
159
  });
80
160
 
81
- it("answers a forbidden request with a 403 and an error body", async () => {
82
- const response = await handleError(new ForbiddenError("Not your account"));
161
+ it("answers a malformed query as a 400 with the query's own message", async () => {
162
+ const error = Object.assign(new Error("Invalid attribute: sentDate"), {
163
+ name: "ElectroError",
164
+ });
165
+
166
+ const response = await handleError(error);
83
167
 
84
- assert.equal(response.statusCode, 403);
85
- assert.deepEqual(parseBody(response.body), { message: "Not your account" });
168
+ assert.equal(response.statusCode, 400);
169
+ assert.deepEqual(parseBody(response.body), {
170
+ code: "invalid_request",
171
+ message: "Invalid attribute: sentDate",
172
+ });
86
173
  });
87
174
 
88
175
  // The contract and the emitter cannot drift apart: this asserts the response
package/src/error.ts CHANGED
@@ -3,6 +3,42 @@ import { logger } from "@remit/logger-lambda";
3
3
  import type { APIGatewayProxyResult } from "aws-lambda";
4
4
  import { formatResponse } from "./response.js";
5
5
 
6
+ /**
7
+ * The `code` a refusal carries when it does not name its own. Every error body
8
+ * the API emits is flat `{ code, message }` (issue #371), and `code` is the
9
+ * only field a client may branch on — a message is a sentence for a person and
10
+ * is free to change.
11
+ *
12
+ * A 5xx always answers `internal_error`, whatever the throw site said: a coded
13
+ * 5xx is a mistake at the throw site, not a contract, and a fixed token cannot
14
+ * carry anything about the failure out of the process.
15
+ */
16
+ export const INTERNAL_ERROR_CODE = "internal_error";
17
+
18
+ /**
19
+ * The one sentence any 5xx answers with. A thrown message is written for a log
20
+ * line, not for a stranger — it names hosts, queries and ids — so it is
21
+ * replaced rather than trusted. The real message is already on the error log
22
+ * beside the correlation id the response carries.
23
+ */
24
+ export const INTERNAL_ERROR_MESSAGE = "Internal server error";
25
+
26
+ const CODE_BY_STATUS: Record<number, string> = {
27
+ 400: "invalid_request",
28
+ 401: "unauthorized",
29
+ 403: "forbidden",
30
+ 404: "not_found",
31
+ 409: "conflict",
32
+ 412: "precondition_failed",
33
+ 413: "payload_too_large",
34
+ 422: "unprocessable_entity",
35
+ };
36
+
37
+ export const defaultErrorCode = (statusCode: number): string => {
38
+ if (statusCode >= 500) return INTERNAL_ERROR_CODE;
39
+ return CODE_BY_STATUS[statusCode] ?? "request_refused";
40
+ };
41
+
6
42
  export const handleError = async (
7
43
  error: unknown,
8
44
  ): Promise<APIGatewayProxyResult> => {
@@ -16,20 +52,30 @@ export const handleError = async (
16
52
  },
17
53
  "Error with statusCode",
18
54
  );
19
- // Only an error that opted in gets a coded body, and only below 500:
20
- // a coded 5xx is a mistake at the throw site, not a contract, and the
21
- // status bound keeps it from reaching a client rather than trusting
22
- // every future thrower to leave `publicApiError` alone.
55
+ // An error that opted in names its own code and details, and only below
56
+ // 500: the status bound keeps a coded 5xx from reaching a client rather
57
+ // than trusting every future thrower to leave `publicApiError` alone.
58
+ // Everything else takes the status class's code, so no response goes out
59
+ // without one. A 5xx keeps neither the thrower's code nor its message —
60
+ // one status, one code, one sentence, whatever failed.
23
61
  const statusCode =
24
62
  typeof error.statusCode === "number" ? error.statusCode : 500;
63
+ if (statusCode >= 500) {
64
+ return formatResponse(
65
+ { code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE },
66
+ statusCode,
67
+ );
68
+ }
25
69
  const publicApiError =
26
- statusCode < 500 &&
27
- "publicApiError" in error &&
28
- isPublicApiError(error.publicApiError)
70
+ "publicApiError" in error && isPublicApiError(error.publicApiError)
29
71
  ? error.publicApiError
30
72
  : undefined;
31
73
  return formatResponse(
32
- { message: error.message, ...publicApiError },
74
+ {
75
+ code: defaultErrorCode(statusCode),
76
+ message: error.message,
77
+ ...publicApiError,
78
+ },
33
79
  statusCode,
34
80
  );
35
81
  }
@@ -53,16 +99,25 @@ export const handleError = async (
53
99
  ? "Database temporarily unavailable"
54
100
  : error.message;
55
101
 
56
- return formatResponse({ message: responseMessage }, statusCode);
102
+ return formatResponse(
103
+ { code: defaultErrorCode(statusCode), message: responseMessage },
104
+ statusCode,
105
+ );
57
106
  }
58
107
 
59
108
  logger.error(
60
109
  { error: error.message, name: error.name, stack: error.stack },
61
110
  "Unhandled Error",
62
111
  );
63
- return formatResponse({ message: "Internal server error" }, 500);
112
+ return formatResponse(
113
+ { code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE },
114
+ 500,
115
+ );
64
116
  }
65
117
 
66
118
  logger.error({ error: JSON.stringify(error) }, "Unknown error type");
67
- return formatResponse({ message: "Internal server error" }, 500);
119
+ return formatResponse(
120
+ { code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE },
121
+ 500,
122
+ );
68
123
  };
@@ -0,0 +1,67 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { AccountAuthType } from "@remit/domain-enums";
4
+ import type { APIGatewayProxyResult } from "aws-lambda";
5
+ import { handleError } from "../error.js";
6
+ import {
7
+ assertNotOAuthCreate,
8
+ assertPasswordProvided,
9
+ } from "./account-guards.js";
10
+
11
+ const refusalOf = (guard: () => void): Promise<APIGatewayProxyResult> => {
12
+ let thrown: unknown;
13
+ assert.throws(guard, (error: unknown) => {
14
+ thrown = error;
15
+ return true;
16
+ });
17
+ return handleError(thrown);
18
+ };
19
+
20
+ // Both guards used to throw `{ status: 400 }`, and the emitter reads
21
+ // `statusCode`. The status never matched, so a create the API meant to refuse
22
+ // as malformed answered 500 with "Internal server error" — telling the caller
23
+ // the server broke rather than what to change (issue #371).
24
+ describe("the create-account guards", () => {
25
+ it("refuses an OAuth create as a 400 the caller can act on", async () => {
26
+ const response = await refusalOf(() =>
27
+ assertNotOAuthCreate(AccountAuthType.OauthMicrosoft),
28
+ );
29
+
30
+ assert.equal(response.statusCode, 400);
31
+ const body = JSON.parse(response.body) as {
32
+ code: string;
33
+ message: string;
34
+ };
35
+ assert.equal(body.code, "invalid_request");
36
+ assert.match(body.message, /OAuth connect flow/);
37
+ });
38
+
39
+ it("refuses a password create with no password as a 400", async () => {
40
+ const response = await refusalOf(() =>
41
+ assertPasswordProvided(AccountAuthType.Password, undefined),
42
+ );
43
+
44
+ assert.equal(response.statusCode, 400);
45
+ const body = JSON.parse(response.body) as {
46
+ code: string;
47
+ message: string;
48
+ };
49
+ assert.equal(body.code, "invalid_request");
50
+ assert.match(body.message, /password is required/);
51
+ });
52
+
53
+ it("refuses a create that omits authType and password alike", async () => {
54
+ const response = await refusalOf(() =>
55
+ assertPasswordProvided(undefined, undefined),
56
+ );
57
+
58
+ assert.equal(response.statusCode, 400);
59
+ });
60
+
61
+ it("lets a password create with a password through", () => {
62
+ assert.doesNotThrow(() =>
63
+ assertPasswordProvided(AccountAuthType.Password, "hunter2"),
64
+ );
65
+ assert.doesNotThrow(() => assertNotOAuthCreate(AccountAuthType.Password));
66
+ });
67
+ });
@@ -3,7 +3,7 @@ import type {
3
3
  FolderAppointment,
4
4
  } from "@remit/api-openapi-types";
5
5
  import type { AccountItem } from "@remit/data-ports";
6
- import { ConflictError } from "@remit/data-ports/errors";
6
+ import { BadRequestError, ConflictError } from "@remit/data-ports/errors";
7
7
  import { AccountAuthType } from "@remit/domain-enums";
8
8
  import type { AccountOverrides } from "./account-overrides.js";
9
9
  import type { AccountSignature } from "./account-signature.js";
@@ -65,11 +65,9 @@ export const assertNoDuplicateMailbox = (
65
65
  /** Rejects OAuth accounts that must go through the dedicated connect flow. */
66
66
  export const assertNotOAuthCreate = (authType: string | undefined): void => {
67
67
  if (authType === AccountAuthType.OauthMicrosoft) {
68
- throw Object.assign(new Error("Bad Request"), {
69
- status: 400,
70
- message:
71
- "OAuth accounts must be created via the OAuth connect flow (POST /accounts/oauth/microsoft/start)",
72
- });
68
+ throw new BadRequestError(
69
+ "OAuth accounts must be created via the OAuth connect flow (POST /accounts/oauth/microsoft/start)",
70
+ );
73
71
  }
74
72
  };
75
73
 
@@ -80,11 +78,9 @@ export const assertPasswordProvided = (
80
78
  ): void => {
81
79
  const isPasswordAuth = authType === AccountAuthType.Password || !authType;
82
80
  if (isPasswordAuth && !password) {
83
- throw Object.assign(new Error("Bad Request"), {
84
- status: 400,
85
- message:
86
- "password is required when authType is 'password' (or when authType is omitted)",
87
- });
81
+ throw new BadRequestError(
82
+ "password is required when authType is 'password' (or when authType is omitted)",
83
+ );
88
84
  }
89
85
  };
90
86
 
@@ -110,6 +110,7 @@ const assertNamesOnly = (log: string, condition: string): void => {
110
110
  const assertGenericFiveHundred = (response: APIGatewayProxyResult): void => {
111
111
  assert.equal(response.statusCode, 500);
112
112
  assert.deepEqual(JSON.parse(response.body), {
113
+ code: "internal_error",
113
114
  message: "Internal server error",
114
115
  });
115
116
  };
@@ -239,7 +239,7 @@ describe("GET /calendar-events", () => {
239
239
  );
240
240
 
241
241
  assert.equal(listed.statusCode, 404);
242
- assert.equal((listed.body as { code: string }).code, "NotFound");
242
+ assert.equal((listed.body as { code: string }).code, "not_found");
243
243
  });
244
244
 
245
245
  it("refuses a window that runs backwards or covers more than a year", async () => {
@@ -259,7 +259,7 @@ describe("GET /calendar-events", () => {
259
259
  );
260
260
 
261
261
  assert.equal(backwards.statusCode, 400);
262
- assert.equal((backwards.body as { code: string }).code, "InvalidWindow");
262
+ assert.equal((backwards.body as { code: string }).code, "invalid_window");
263
263
  assert.equal(tooWide.statusCode, 400);
264
264
  });
265
265
  });
@@ -285,7 +285,7 @@ describe("POST /calendar-events", () => {
285
285
  assert.equal(created.statusCode, 400);
286
286
  assert.equal(
287
287
  (created.body as { code: string }).code,
288
- "InvalidRecurrenceRule",
288
+ "invalid_recurrence_rule",
289
289
  );
290
290
  assert.deepEqual(
291
291
  await client.calendarObject.listByCalendar(calendarId),
@@ -314,7 +314,7 @@ describe("POST /calendar-events", () => {
314
314
  );
315
315
 
316
316
  assert.equal(created.statusCode, 400);
317
- assert.equal((created.body as { code: string }).code, "UnknownTimeZone");
317
+ assert.equal((created.body as { code: string }).code, "unknown_time_zone");
318
318
  assert.deepEqual(
319
319
  await client.calendarObject.listByCalendar(calendarId),
320
320
  [],
@@ -403,7 +403,10 @@ describe("DELETE /calendar-events/{calendarObjectId}", () => {
403
403
  );
404
404
 
405
405
  assert.equal(removed.statusCode, 412);
406
- assert.equal((removed.body as { code: string }).code, "EtagMismatch");
406
+ assert.equal(
407
+ (removed.body as { code: string }).code,
408
+ "precondition_failed",
409
+ );
407
410
  const survivor = await client.calendarObject.find(
408
411
  calendarId,
409
412
  created.calendarObjectId,
@@ -545,9 +548,9 @@ describe("GET /calendar-free-busy", () => {
545
548
  );
546
549
 
547
550
  assert.equal(missing.statusCode, 400);
548
- assert.equal((missing.body as { code: string }).code, "InvalidWindow");
551
+ assert.equal((missing.body as { code: string }).code, "invalid_window");
549
552
  assert.equal(tooWide.statusCode, 400);
550
- assert.equal((tooWide.body as { code: string }).code, "InvalidWindow");
553
+ assert.equal((tooWide.body as { code: string }).code, "invalid_window");
551
554
  });
552
555
  });
553
556
 
@@ -787,7 +787,7 @@ describe("the calendar collection wrappers", () => {
787
787
  );
788
788
 
789
789
  assert.equal(read.statusCode, 404);
790
- assert.equal((read.body as { code: string }).code, "NotFound");
790
+ assert.equal((read.body as { code: string }).code, "not_found");
791
791
  });
792
792
 
793
793
  it("refuses a second calendar under a segment the account already uses", async () => {
@@ -805,7 +805,7 @@ describe("the calendar collection wrappers", () => {
805
805
  );
806
806
 
807
807
  assert.equal(second.statusCode, 400);
808
- assert.equal((second.body as { code: string }).code, "UrlSegmentTaken");
808
+ assert.equal((second.body as { code: string }).code, "url_segment_taken");
809
809
  });
810
810
 
811
811
  it("refuses a rename that names a zone this server cannot resolve", async () => {
@@ -826,7 +826,7 @@ describe("the calendar collection wrappers", () => {
826
826
  );
827
827
 
828
828
  assert.equal(updated.statusCode, 400);
829
- assert.equal((updated.body as { code: string }).code, "UnknownTimeZone");
829
+ assert.equal((updated.body as { code: string }).code, "unknown_time_zone");
830
830
  });
831
831
 
832
832
  it("answers 204 for a calendar it removed and 400 for the default one", async () => {
@@ -858,7 +858,7 @@ describe("the calendar collection wrappers", () => {
858
858
  assert.equal(refused.statusCode, 400);
859
859
  assert.equal(
860
860
  (refused.body as { code: string }).code,
861
- "DefaultCalendarUndeletable",
861
+ "default_calendar_undeletable",
862
862
  );
863
863
  });
864
864
  });
@@ -22,6 +22,7 @@ import { normalizeCalendarUrlSegment } from "@remit/data-ports/id";
22
22
  import { CalendarSource } from "@remit/domain-enums";
23
23
  import type { APIGatewayProxyEvent } from "aws-lambda";
24
24
  import { getAccountConfigIdFromEvent } from "../auth.js";
25
+ import { defaultErrorCode } from "../error.js";
25
26
  import type { RemitClient } from "../service/data-client.js";
26
27
  import { getClient } from "../service/data-client.js";
27
28
  import type {
@@ -61,19 +62,35 @@ export const refuseCalendar = <T>(
61
62
  message: string,
62
63
  ): CalendarOutcome<T> => ({ ok: false, error: { code, message } });
63
64
 
65
+ /**
66
+ * `CalendarRefusal.code` is this module's and `@remit/calendar-service`'s own
67
+ * routing token, in PascalCase; the API's `code` vocabulary is snake_case
68
+ * (issue #371). Derived rather than mapped by hand, so a refusal added to
69
+ * either producer reaches the wire under its own name instead of silently
70
+ * collapsing into the status-class code.
71
+ */
72
+ const wireCode = (code: string): string =>
73
+ code.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
74
+
75
+ /**
76
+ * A 400 keeps the refusal's own code — `unknown_time_zone` and
77
+ * `url_segment_taken` are what a client branches on, and the status class alone
78
+ * would not distinguish them. A 404 and a 412 have nothing to add to their
79
+ * status, so they take the shared token.
80
+ */
64
81
  export const badRequest = (error: CalendarRefusal) => ({
65
82
  statusCode: 400,
66
- body: error,
83
+ body: { code: wireCode(error.code), message: error.message },
67
84
  });
68
85
 
69
86
  export const notFound = (message: string) => ({
70
87
  statusCode: 404,
71
- body: { code: "NotFound", message },
88
+ body: { code: defaultErrorCode(404), message },
72
89
  });
73
90
 
74
91
  export const preconditionFailed = (message: string) => ({
75
92
  statusCode: 412,
76
- body: { code: "EtagMismatch", message },
93
+ body: { code: defaultErrorCode(412), message },
77
94
  });
78
95
 
79
96
  /** The calendar half of the client, named so a handler takes only what it uses. */
@@ -14,6 +14,10 @@ import type { Context } from "openapi-backend";
14
14
  import { getAccountConfigIdFromEvent } from "../auth.js";
15
15
  import { getClient } from "../service/data-client.js";
16
16
 
17
+ // Every error body the API emits carries a `code` (issue #371). A refusal here
18
+ // is one kind, so the code is one value and `reason` says which refusal it was.
19
+ const ATTACHMENT_REJECTED_CODE = "attachment_rejected";
20
+
17
21
  const TOO_LARGE: ReadonlySet<OutboxAttachmentRejectionReasonValue> = new Set([
18
22
  OutboxAttachmentRejectionReason.FileTooLarge,
19
23
  OutboxAttachmentRejectionReason.MessageTooLarge,
@@ -30,6 +34,7 @@ const refuse = (
30
34
  ): RejectionResponse => ({
31
35
  statusCode: TOO_LARGE.has(detail.reason) ? 413 : 400,
32
36
  body: {
37
+ code: ATTACHMENT_REJECTED_CODE,
33
38
  reason: detail.reason,
34
39
  message: detail.message,
35
40
  limitBytes: detail.limitBytes,
@@ -13,6 +13,7 @@ import { fileURLToPath } from "node:url";
13
13
  import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
14
14
  import type { Context } from "openapi-backend";
15
15
  import { normalizeRequest } from "../request.js";
16
+ import { formatResponse } from "../response.js";
16
17
  import { SystemOperations } from "./system-update.js";
17
18
 
18
19
  const tmpRoot = join(
@@ -448,3 +449,49 @@ describe("GET /system/update through the whole request pipeline", () => {
448
449
  assert.deepEqual(JSON.parse(result.body), okState);
449
450
  });
450
451
  });
452
+
453
+ // A refusal here is the object `formatResponse` unwraps, never one that has
454
+ // already been serialized: handing it a `body` string publishes the envelope
455
+ // itself — `{"statusCode":…,"headers":…,"body":"{…}"}` — and what the caller
456
+ // then parses is not an ApiError at all. Only the status was ever asserted, so
457
+ // the shape went unguarded (issue #371).
458
+ describe("the wire body of a self-update refusal", () => {
459
+ it("answers an unconfigured seam with a flat not_found", async () => {
460
+ delete process.env.REMIT_UPDATE_MANIFEST_URL;
461
+
462
+ const result = await getUpdate(buildEvent(USER));
463
+ const wire = formatResponse(result as Record<string, unknown>);
464
+
465
+ assert.equal(wire.statusCode, 404);
466
+ assert.deepEqual(JSON.parse(wire.body), {
467
+ code: "not_found",
468
+ message: "Not found",
469
+ });
470
+ });
471
+
472
+ it("answers an unauthenticated caller with a flat unauthorized", async () => {
473
+ writeState(okState);
474
+
475
+ const result = await getUpdate(buildEvent());
476
+ const wire = formatResponse(result as Record<string, unknown>);
477
+
478
+ assert.equal(wire.statusCode, 401);
479
+ assert.deepEqual(JSON.parse(wire.body), {
480
+ code: "unauthorized",
481
+ message: "Unauthorized",
482
+ });
483
+ });
484
+
485
+ it("answers an unauthenticated install request the same way", async () => {
486
+ writeState(okState);
487
+
488
+ const result = await applyUpdate("v1.5.0", buildEvent());
489
+ const wire = formatResponse(result as Record<string, unknown>);
490
+
491
+ assert.equal(wire.statusCode, 401);
492
+ assert.deepEqual(JSON.parse(wire.body), {
493
+ code: "unauthorized",
494
+ message: "Unauthorized",
495
+ });
496
+ });
497
+ });
@@ -9,6 +9,7 @@ import { ConflictError, HTTPError } from "@remit/data-ports/errors";
9
9
  import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
10
10
  import type { Context } from "openapi-backend";
11
11
  import { getSubFromEvent } from "../auth.js";
12
+ import { defaultErrorCode } from "../error.js";
12
13
  import type { OperationHandler, SystemOperationIds } from "../types.js";
13
14
 
14
15
  class TargetVersionMismatchError extends HTTPError {
@@ -36,17 +37,21 @@ const manifestUrl = (): string | undefined =>
36
37
  */
37
38
  const UNKNOWN_VERSION = "unknown";
38
39
 
39
- const notFound = (): APIGatewayProxyResult => ({
40
- statusCode: 404,
41
- headers: { "Content-Type": "application/json" },
42
- body: JSON.stringify({ message: "Not found" }),
43
- });
40
+ /**
41
+ * A refusal in the shape `formatResponse` unwraps: the status, and the body as
42
+ * an object it serializes. Handing it an already-serialized `body` string
43
+ * instead publishes the whole envelope — `{"statusCode":…,"headers":…}` with
44
+ * the real body escaped inside it — which is not an `ApiError` at all.
45
+ */
46
+ const refuse = (statusCode: number, message: string): APIGatewayProxyResult =>
47
+ ({
48
+ statusCode,
49
+ body: { code: defaultErrorCode(statusCode), message },
50
+ }) as unknown as APIGatewayProxyResult;
44
51
 
45
- const unauthorized = (): APIGatewayProxyResult => ({
46
- statusCode: 401,
47
- headers: { "Content-Type": "application/json" },
48
- body: JSON.stringify({ message: "Unauthorized" }),
49
- });
52
+ const notFound = (): APIGatewayProxyResult => refuse(404, "Not found");
53
+
54
+ const unauthorized = (): APIGatewayProxyResult => refuse(401, "Unauthorized");
50
55
 
51
56
  /**
52
57
  * The self-update seam is off unless a manifest URL is configured (RFC 037 D8),
package/src/index.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  } from "openapi-backend";
14
14
  import { assertLocalBypassNotInDeployedEnv } from "./auth.js";
15
15
  import { usesBetterAuthJwt } from "./data-backend.js";
16
- import { handleError } from "./error.js";
16
+ import { defaultErrorCode, handleError } from "./error.js";
17
17
  import { handlers } from "./handlers/index.js";
18
18
  import { authenticateSelfHostRequest } from "./jwt-auth.js";
19
19
  import { normalizeRequest } from "./request.js";
@@ -90,6 +90,22 @@ const api = new OpenAPIBackend({
90
90
 
91
91
  api.register("postResponseHandler", postResponseHandler);
92
92
 
93
+ /**
94
+ * The validator's findings, narrowed to the three fields `RequestValidationDetail`
95
+ * declares. Copied field by field rather than passed through: an AJV error also
96
+ * carries `params`, and under `verbose` the rejected `data` itself — which for a
97
+ * refused request body is whatever the caller sent, a password among the
98
+ * possibilities. Naming the fields is what keeps that off the wire.
99
+ */
100
+ const validationDetails = (
101
+ errors: OpenAPIContext["validation"]["errors"],
102
+ ): Array<{ path: string; rule: string; message: string }> =>
103
+ (errors ?? []).map((error) => ({
104
+ path: error.instancePath,
105
+ rule: error.keyword,
106
+ message: error.message ?? "refused",
107
+ }));
108
+
93
109
  // The errors and the operation, and nothing that came off the wire. A request
94
110
  // that fails validation is logged at error level, and the request it carries
95
111
  // holds the caller's headers and cookies — an Authorization bearer among them,
@@ -107,13 +123,17 @@ api.register("validationFail", (c: OpenAPIContext, req: Request) => {
107
123
  return {
108
124
  statusCode: 400,
109
125
  body: {
126
+ code: defaultErrorCode(400),
110
127
  message: "Invalid request",
111
- errors: c.validation.errors,
128
+ errors: validationDetails(c.validation.errors),
112
129
  },
113
130
  };
114
131
  });
115
132
 
116
- api.register("notFound", () => ({ statusCode: 404 }));
133
+ api.register("notFound", () => ({
134
+ statusCode: 404,
135
+ body: { code: defaultErrorCode(404), message: "Not found" },
136
+ }));
117
137
 
118
138
  api.register("notImplemented", async (c: OpenAPIContext) => {
119
139
  const { status, mock } = api.mockResponseForOperation(
package/src/jwt-auth.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
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
+ import { defaultErrorCode } from "./error.js";
10
11
 
11
12
  let verifier: JwtVerifier | null = null;
12
13
 
@@ -33,7 +34,7 @@ const readHeader = (
33
34
  const unauthorized = (message: string): APIGatewayProxyResult => ({
34
35
  statusCode: 401,
35
36
  headers: { "Content-Type": "application/json" },
36
- body: JSON.stringify({ message }),
37
+ body: JSON.stringify({ code: defaultErrorCode(401), message }),
37
38
  });
38
39
 
39
40
  const injectClaims = (
@@ -28,6 +28,80 @@ const queryParameters = (): Array<{
28
28
  return found;
29
29
  };
30
30
 
31
+ type Schema = {
32
+ $ref?: string;
33
+ type?: string;
34
+ required?: string[];
35
+ properties?: Record<string, unknown>;
36
+ };
37
+
38
+ const resolve = (schema: Schema | undefined): Schema | undefined => {
39
+ if (!schema?.$ref) return schema;
40
+ const name = schema.$ref.replace("#/components/schemas/", "");
41
+ return (OpenAPISpec.components?.schemas?.[name] ?? undefined) as
42
+ | Schema
43
+ | undefined;
44
+ };
45
+
46
+ const errorBodySchemas = (): Array<{
47
+ operationId: string;
48
+ status: string;
49
+ schema: Schema | undefined;
50
+ }> => {
51
+ const found: Array<{
52
+ operationId: string;
53
+ status: string;
54
+ schema: Schema | undefined;
55
+ }> = [];
56
+ for (const item of Object.values(OpenAPISpec.paths ?? {})) {
57
+ for (const operation of Object.values(item ?? {})) {
58
+ const { operationId, responses } = operation as {
59
+ operationId?: string;
60
+ responses?: Record<
61
+ string,
62
+ { content?: Record<string, { schema?: Schema }> }
63
+ >;
64
+ };
65
+ if (!operationId || !responses) continue;
66
+ for (const [status, response] of Object.entries(responses)) {
67
+ if (Number(status) < 400) continue;
68
+ const schema = response.content?.["application/json"]?.schema;
69
+ if (!schema) continue;
70
+ found.push({ operationId, status, schema: resolve(schema) });
71
+ }
72
+ }
73
+ }
74
+ return found;
75
+ };
76
+
77
+ // Issue #371: one error shape for the whole API. Every JSON refusal body is
78
+ // flat — `code` and `message` at the top level, no `error` envelope — and
79
+ // `code` is required, because it is the only field a client may branch on.
80
+ describe("the declared shape of an error response", () => {
81
+ it("is a flat object carrying a required code and message", () => {
82
+ const offenders = errorBodySchemas()
83
+ .filter(({ schema }) => {
84
+ const required = schema?.required ?? [];
85
+ return (
86
+ schema?.type !== "object" ||
87
+ !required.includes("code") ||
88
+ !required.includes("message")
89
+ );
90
+ })
91
+ .map(({ operationId, status }) => `${operationId}.${status}`);
92
+
93
+ assert.deepEqual(offenders, []);
94
+ });
95
+
96
+ it("never wraps the body in an envelope property", () => {
97
+ const offenders = errorBodySchemas()
98
+ .filter(({ schema }) => schema?.properties?.error !== undefined)
99
+ .map(({ operationId, status }) => `${operationId}.${status}`);
100
+
101
+ assert.deepEqual(offenders, []);
102
+ });
103
+ });
104
+
31
105
  describe("the built OpenAPI spec", () => {
32
106
  it("declares every query parameter exploded", () => {
33
107
  // openapi-backend splits a non-exploded query value on commas before it
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The two refusals no handler ever sees: a path the spec does not route, and a
3
+ * request the spec refuses before routing. Both are answered by the framework,
4
+ * and both used to leave the one shape the API promises (issue #371) — the
5
+ * unmatched route with no body at all, the refused request with no `code` and
6
+ * with the validator's raw findings beside it.
7
+ */
8
+ import assert from "node:assert/strict";
9
+ import { describe, it } from "node:test";
10
+ import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
11
+ import { api } from "./index.js";
12
+ import { normalizeRequest } from "./request.js";
13
+
14
+ const SUB = "cognito-sub-pipeline";
15
+
16
+ const send = async (
17
+ method: string,
18
+ path: string,
19
+ body?: string,
20
+ ): Promise<APIGatewayProxyResult> => {
21
+ const event = {
22
+ httpMethod: method,
23
+ path,
24
+ body: body ?? null,
25
+ queryStringParameters: null,
26
+ headers: body ? { "content-type": "application/json" } : {},
27
+ requestContext: { authorizer: { claims: { sub: SUB } } },
28
+ } as unknown as APIGatewayProxyEvent;
29
+
30
+ return (await api.handleRequest(
31
+ normalizeRequest(event),
32
+ event,
33
+ {} as never,
34
+ )) as APIGatewayProxyResult;
35
+ };
36
+
37
+ describe("a path the spec does not route", () => {
38
+ it("answers 404 with a body, not an empty one", async () => {
39
+ const response = await send("GET", "/no-such-resource");
40
+
41
+ assert.equal(response.statusCode, 404);
42
+ assert.deepEqual(JSON.parse(response.body), {
43
+ code: "not_found",
44
+ message: "Not found",
45
+ });
46
+ });
47
+ });
48
+
49
+ describe("a request the spec refuses before any handler runs", () => {
50
+ it("answers 400 with a code and the fields it refused", async () => {
51
+ const response = await send(
52
+ "POST",
53
+ "/calendars",
54
+ JSON.stringify({ displayName: 42 }),
55
+ );
56
+
57
+ assert.equal(response.statusCode, 400);
58
+ const body = JSON.parse(response.body) as {
59
+ code: string;
60
+ message: string;
61
+ errors: { path: string; rule: string; message: string }[];
62
+ };
63
+
64
+ assert.equal(body.code, "invalid_request");
65
+ assert.equal(body.message, "Invalid request");
66
+ assert.ok(body.errors.length > 0);
67
+ for (const detail of body.errors) {
68
+ assert.deepEqual(Object.keys(detail).sort(), ["message", "path", "rule"]);
69
+ assert.equal(typeof detail.path, "string");
70
+ assert.equal(typeof detail.rule, "string");
71
+ assert.equal(typeof detail.message, "string");
72
+ }
73
+ });
74
+
75
+ // An AJV error also carries `params`, and under `verbose` the rejected value
76
+ // itself. A refused create body is whatever the caller sent — a password
77
+ // among the possibilities — so nothing but the three named fields may travel.
78
+ it("never echoes the value it refused", async () => {
79
+ const response = await send(
80
+ "POST",
81
+ "/accounts",
82
+ JSON.stringify({ password: "hunter2", imapPort: "not-a-number" }),
83
+ );
84
+
85
+ assert.equal(response.statusCode, 400);
86
+ assert.ok(!response.body.includes("hunter2"));
87
+ });
88
+ });