@remit/backend 0.0.82 → 0.0.84

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.82",
3
+ "version": "0.0.84",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -1,10 +1,27 @@
1
1
  import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
2
+ import { afterEach, describe, it, mock } from "node:test";
3
+ import type { SendMessageCommand } from "@aws-sdk/client-sqs";
3
4
  import type { OrganizeInput } from "@remit/api-openapi-types";
5
+ import type {
6
+ CreateOrganizeJobRequestInput,
7
+ IAccountRepository,
8
+ IOrganizeJobRequestRepository,
9
+ OrganizeJobRequestItem,
10
+ } from "@remit/data-ports";
4
11
  import { BadRequestError } from "@remit/data-ports/errors";
5
12
  import { FilterMatchOperator } from "@remit/domain-enums";
13
+ import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
14
+ import type { Context } from "openapi-backend";
15
+ import { deriveAccountConfigId } from "../auth.js";
6
16
  import { handleError } from "../error.js";
7
- import { predicateFromInput } from "./organize.js";
17
+ import { formatResponse } from "../response.js";
18
+ import {
19
+ _resetForTest,
20
+ type RemitClient,
21
+ setClient,
22
+ } from "../service/data-client.js";
23
+ import { sqsClient } from "../service/sqs.js";
24
+ import { OrganizeOperations, predicateFromInput } from "./organize.js";
8
25
 
9
26
  const input = (over: Partial<OrganizeInput> = {}): OrganizeInput => ({
10
27
  matchOperator: FilterMatchOperator.And,
@@ -61,3 +78,111 @@ describe("previewOrganize rejected-rule response (reader #457)", () => {
61
78
  assert.match(JSON.parse(response.body).message, /HasWords/);
62
79
  });
63
80
  });
81
+
82
+ const SUB = "cognito-sub-995";
83
+ const ACCOUNT_CONFIG_ID = deriveAccountConfigId(SUB);
84
+ const ACCOUNT_ID = "acc-995";
85
+
86
+ const createdRows: CreateOrganizeJobRequestInput[] = [];
87
+ const enqueued: SendMessageCommand[] = [];
88
+
89
+ const installClient = (): void => {
90
+ createdRows.length = 0;
91
+ setClient({
92
+ account: {
93
+ get: async () => ({
94
+ accountId: ACCOUNT_ID,
95
+ accountConfigId: ACCOUNT_CONFIG_ID,
96
+ }),
97
+ } as unknown as IAccountRepository,
98
+ organizeJobRequest: {
99
+ create: async (input: CreateOrganizeJobRequestInput) => {
100
+ createdRows.push(input);
101
+ return {
102
+ ...input,
103
+ organizeJobId: `job-${createdRows.length}`,
104
+ state: "Pending",
105
+ } as unknown as OrganizeJobRequestItem;
106
+ },
107
+ } as unknown as IOrganizeJobRequestRepository,
108
+ } as unknown as RemitClient);
109
+ };
110
+
111
+ const authorizedEvent = (): APIGatewayProxyEvent =>
112
+ ({
113
+ body: null,
114
+ requestContext: { authorizer: { claims: { sub: SUB } } },
115
+ }) as unknown as APIGatewayProxyEvent;
116
+
117
+ const createJob = OrganizeOperations.OrganizeOperations_createOrganizeJob as (
118
+ context: Context,
119
+ event: APIGatewayProxyEvent,
120
+ ) => Promise<Record<string, unknown>>;
121
+
122
+ /** The response the browser receives, error funnel included. */
123
+ const postOrganize = async (
124
+ body: OrganizeInput,
125
+ ): Promise<APIGatewayProxyResult> => {
126
+ const context = {
127
+ request: { params: { accountId: ACCOUNT_ID }, requestBody: body },
128
+ } as unknown as Context;
129
+ return createJob(context, authorizedEvent()).then(
130
+ (response) => formatResponse(response),
131
+ (error: unknown) => handleError(error),
132
+ );
133
+ };
134
+
135
+ // A rule the matcher can never honour — a body-content clause with no anchor to
136
+ // widen from — is refused on the request instead of accepted with a 202 for a
137
+ // job the worker can only fail (reader #463, #995). The contract now declares
138
+ // the 400, so the proof is the response the client gets plus the absence of the
139
+ // two side effects a 202 promises: a job row and a queued message.
140
+ describe("createOrganizeJob rejected-rule refusal (reader #995)", () => {
141
+ afterEach(() => {
142
+ mock.restoreAll();
143
+ _resetForTest();
144
+ });
145
+
146
+ const withStubbedQueue = (): void => {
147
+ process.env.SQS_QUEUE_URL_ACCOUNT_FANOUT =
148
+ "http://localhost:9324/queue/account-fanout-test";
149
+ enqueued.length = 0;
150
+ mock.method(sqsClient, "send", async (command: SendMessageCommand) => {
151
+ enqueued.push(command);
152
+ return {};
153
+ });
154
+ installClient();
155
+ };
156
+
157
+ it("refuses an anchorless body-content clause with a 400 and creates nothing", async () => {
158
+ withStubbedQueue();
159
+
160
+ const response = await postOrganize(
161
+ input({ literalClauses: [{ field: "HasWords", value: "invoice" }] }),
162
+ );
163
+
164
+ assert.equal(response.statusCode, 400);
165
+ assert.match(JSON.parse(response.body).message, /HasWords/);
166
+ assert.deepEqual(createdRows, []);
167
+ assert.deepEqual(enqueued, []);
168
+ });
169
+
170
+ it("accepts the same clause when an anchor gives the widen something to run on", async () => {
171
+ withStubbedQueue();
172
+
173
+ const response = await postOrganize(
174
+ input({
175
+ anchorMessageId: "msg-anchor",
176
+ literalClauses: [{ field: "HasWords", value: "invoice" }],
177
+ }),
178
+ );
179
+
180
+ assert.equal(response.statusCode, 202);
181
+ assert.equal(createdRows.length, 1);
182
+ assert.equal(enqueued.length, 1);
183
+ assert.match(
184
+ String(enqueued[0]?.input.MessageBody),
185
+ new RegExp(String(JSON.parse(response.body).organizeJobId)),
186
+ );
187
+ });
188
+ });
@@ -17,6 +17,7 @@ import {
17
17
  matchOrganize,
18
18
  ORGANIZE_MATCH_LIMIT,
19
19
  type OrganizePredicate,
20
+ organizePredicateRejection,
20
21
  } from "../service/organize.js";
21
22
  import { sqsClient } from "../service/sqs.js";
22
23
  import type {
@@ -107,6 +108,12 @@ export const OrganizeOperations: Record<
107
108
  await assertAccount(client, accountId, accountConfigId, "act");
108
109
 
109
110
  const predicate = predicateFromInput(input);
111
+
112
+ // A rule the matcher can never honour is answered here, not with a 202
113
+ // the worker has to fail later (reader #463).
114
+ const rejection = organizePredicateRejection(predicate);
115
+ if (rejection) throw new BadRequestError(rejection.message);
116
+
110
117
  const ttl = Math.floor(Date.now() / 1000) + ORGANIZE_JOB_TTL_SECONDS;
111
118
 
112
119
  const job = await client.organizeJobRequest.create({
@@ -72,6 +72,35 @@ test("no token and no bypass returns 401", async () => {
72
72
  assert.equal(result?.statusCode, 401);
73
73
  });
74
74
 
75
+ test("a tokenless GET to the Microsoft OAuth callback is admitted", async () => {
76
+ const event = buildEvent({
77
+ httpMethod: "GET",
78
+ path: "/accounts/oauth/microsoft/callback",
79
+ });
80
+
81
+ const result = await authenticateSelfHostRequest(event);
82
+
83
+ assert.equal(result, null);
84
+ assert.equal(event.requestContext.authorizer, undefined);
85
+ });
86
+
87
+ test("the callback exemption covers that path and method only", async () => {
88
+ const neighbour = buildEvent({
89
+ httpMethod: "GET",
90
+ path: "/accounts/oauth/microsoft/start",
91
+ });
92
+ const otherMethod = buildEvent({
93
+ httpMethod: "POST",
94
+ path: "/accounts/oauth/microsoft/callback",
95
+ });
96
+
97
+ assert.equal((await authenticateSelfHostRequest(neighbour))?.statusCode, 401);
98
+ assert.equal(
99
+ (await authenticateSelfHostRequest(otherMethod))?.statusCode,
100
+ 401,
101
+ );
102
+ });
103
+
75
104
  test("pre-injected claims (edge tier) short-circuit verification", async () => {
76
105
  _setVerifierForTest(async () => {
77
106
  throw new Error("verifier must not be called");
package/src/jwt-auth.ts CHANGED
@@ -55,6 +55,26 @@ const injectClaims = (
55
55
  const hasLocalBypass = (): boolean =>
56
56
  Boolean(process.env.LOCAL_ACCOUNT_CONFIG_ID);
57
57
 
58
+ /**
59
+ * The one route that runs without a token.
60
+ *
61
+ * Microsoft redirects the browser here after consent, so the request carries no
62
+ * Authorization header and no session to derive one from. The handler takes its
63
+ * identity from the HMAC-signed `state` parameter and validates it there; that
64
+ * signature is the gate, not a JWT.
65
+ *
66
+ * Hardcoded and single-entry, matching the edge exemption in
67
+ * packages/apisix/src/route-table.ts. `@useAuth(NoAuth)` in the spec does not
68
+ * open a path here — a future public route is added by hand.
69
+ */
70
+ const PUBLIC_ROUTE = {
71
+ method: "GET",
72
+ path: "/accounts/oauth/microsoft/callback",
73
+ };
74
+
75
+ const isPublicRoute = (event: APIGatewayProxyEvent): boolean =>
76
+ event.httpMethod === PUBLIC_ROUTE.method && event.path === PUBLIC_ROUTE.path;
77
+
58
78
  /**
59
79
  * Authenticate a self-host request from a better-auth RS256 JWT.
60
80
  *
@@ -71,6 +91,8 @@ const hasLocalBypass = (): boolean =>
71
91
  export const authenticateSelfHostRequest = async (
72
92
  event: APIGatewayProxyEvent,
73
93
  ): Promise<APIGatewayProxyResult | null> => {
94
+ if (isPublicRoute(event)) return null;
95
+
74
96
  const existingSub = event.requestContext?.authorizer?.claims?.sub;
75
97
  if (typeof existingSub === "string" && existingSub.length > 0) return null;
76
98
 
@@ -370,6 +370,19 @@ const bodyContentRejection = (
370
370
  }
371
371
  : null;
372
372
 
373
+ /**
374
+ * The refusal a predicate carries on its own, decidable without reading the
375
+ * corpus: an anchorless predicate can only ever take the vector-free literal
376
+ * path, so a body-content clause on it is refused up front. `createOrganizeJob`
377
+ * runs this before enqueueing and {@link matchOrganize} runs it on the
378
+ * anchorless arm, so preview, the worker and the request boundary all refuse the
379
+ * same rule (reader #463).
380
+ */
381
+ export const organizePredicateRejection = (
382
+ predicate: OrganizePredicate,
383
+ ): OrganizeRejection | null =>
384
+ hasAnchor(predicate) ? null : bodyContentRejection(predicate.literalClauses);
385
+
373
386
  /**
374
387
  * The literal-only arm: scan a bounded, vector-free slice of the corpus and keep
375
388
  * the messages whose literal clauses match. Used both for a purely-literal
@@ -434,7 +447,7 @@ export const matchOrganize = async (
434
447
  }
435
448
 
436
449
  if (!anchored) {
437
- const rejection = bodyContentRejection(clauses);
450
+ const rejection = organizePredicateRejection(predicate);
438
451
  if (rejection) return { rejected: rejection };
439
452
  const messageIds = await matchLiteral(
440
453
  deps,