@remit/backend 0.0.83 → 0.0.85
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/src/config/msoauth.test.ts +48 -0
- package/src/config/msoauth.ts +12 -24
- package/src/handlers/organize.test.ts +127 -2
- package/src/handlers/organize.ts +7 -0
- package/src/service/organize.ts +14 -1
package/package.json
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, describe, it } from "node:test";
|
|
3
|
+
import { getMsOAuthConfig } from "./msoauth.js";
|
|
4
|
+
|
|
5
|
+
const MSOAUTH_KEYS = [
|
|
6
|
+
"MSOAUTH_AUTHORITY",
|
|
7
|
+
"MSOAUTH_REDIRECT_URI",
|
|
8
|
+
"MSOAUTH_SECRET_ARN",
|
|
9
|
+
"MSOAUTH_CLIENT_ID",
|
|
10
|
+
"MSOAUTH_CLIENT_SECRET",
|
|
11
|
+
"MSOAUTH_TOKEN_ENDPOINT",
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
describe("getMsOAuthConfig", () => {
|
|
15
|
+
const original = new Map(
|
|
16
|
+
MSOAUTH_KEYS.map((key) => [key, process.env[key]] as const),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
for (const [key, value] of original) {
|
|
21
|
+
if (value === undefined) delete process.env[key];
|
|
22
|
+
else process.env[key] = value;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("resolves on a self-host deployment, where no authority is wired", () => {
|
|
27
|
+
for (const key of MSOAUTH_KEYS) delete process.env[key];
|
|
28
|
+
process.env.MSOAUTH_REDIRECT_URI =
|
|
29
|
+
"https://mail.example.com/api/accounts/oauth/microsoft/callback";
|
|
30
|
+
process.env.MSOAUTH_CLIENT_ID = "client-id";
|
|
31
|
+
process.env.MSOAUTH_CLIENT_SECRET = "client-secret";
|
|
32
|
+
|
|
33
|
+
assert.deepEqual(getMsOAuthConfig(), {
|
|
34
|
+
secretArn: undefined,
|
|
35
|
+
redirectUri:
|
|
36
|
+
"https://mail.example.com/api/accounts/oauth/microsoft/callback",
|
|
37
|
+
clientId: "client-id",
|
|
38
|
+
clientSecret: "client-secret",
|
|
39
|
+
tokenEndpoint: undefined,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("names PUBLIC_ORIGIN when the redirect URI is missing", () => {
|
|
44
|
+
for (const key of MSOAUTH_KEYS) delete process.env[key];
|
|
45
|
+
|
|
46
|
+
assert.throws(getMsOAuthConfig, /MSOAUTH_REDIRECT_URI.*PUBLIC_ORIGIN/s);
|
|
47
|
+
});
|
|
48
|
+
});
|
package/src/config/msoauth.ts
CHANGED
|
@@ -1,28 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Typed reads for Microsoft OAuth (Entra) environment variables.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* - `MSOAUTH_AUTHORITY` — OIDC authority base URL (baked in at deploy time).
|
|
8
|
-
* - `MSOAUTH_REDIRECT_URI`— OAuth callback URI (baked in at deploy time).
|
|
4
|
+
* `MSOAUTH_REDIRECT_URI` is derived from `PUBLIC_ORIGIN` by the compose file
|
|
5
|
+
* (deploy/vps/docker-compose.sqlite.yml) and must match the redirect URI
|
|
6
|
+
* registered on the Entra app registration.
|
|
9
7
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* Credentials come either from `MSOAUTH_CLIENT_ID` / `MSOAUTH_CLIENT_SECRET`
|
|
9
|
+
* in the deploy env file, or from Secrets Manager via `MSOAUTH_SECRET_ARN`.
|
|
12
10
|
*
|
|
13
|
-
*
|
|
11
|
+
* The Microsoft authorization and token endpoints are fixed in
|
|
12
|
+
* `@remit/mail-oauth-service`; no authority URL is read from the environment.
|
|
14
13
|
*/
|
|
15
14
|
|
|
16
15
|
export interface MsOAuthConfig {
|
|
17
|
-
/** Secrets Manager ARN —
|
|
16
|
+
/** Secrets Manager ARN — an alternative to the client id/secret pair. */
|
|
18
17
|
readonly secretArn: string | undefined;
|
|
19
|
-
/** OIDC authority, e.g. `https://login.microsoftonline.com/common`. */
|
|
20
|
-
readonly authority: string;
|
|
21
18
|
/** OAuth redirect URI registered in the Entra app. */
|
|
22
19
|
readonly redirectUri: string;
|
|
23
|
-
/** Client ID —
|
|
20
|
+
/** Client ID — set directly to bypass Secrets Manager. */
|
|
24
21
|
readonly clientId: string | undefined;
|
|
25
|
-
/** Client secret —
|
|
22
|
+
/** Client secret — set directly to bypass Secrets Manager. */
|
|
26
23
|
readonly clientSecret: string | undefined;
|
|
27
24
|
/** Token endpoint override — used for local stubbing (bypasses default OIDC discovery). */
|
|
28
25
|
readonly tokenEndpoint: string | undefined;
|
|
@@ -30,28 +27,19 @@ export interface MsOAuthConfig {
|
|
|
30
27
|
|
|
31
28
|
/**
|
|
32
29
|
* Read Microsoft OAuth configuration from the environment.
|
|
33
|
-
* Throws when
|
|
34
|
-
* are absent — both are always present in deployed Lambdas and should be set
|
|
35
|
-
* in `localhost-dev-aws.env` for local development.
|
|
30
|
+
* Throws when `MSOAUTH_REDIRECT_URI` is absent.
|
|
36
31
|
*/
|
|
37
32
|
export const getMsOAuthConfig = (): MsOAuthConfig => {
|
|
38
|
-
const authority = process.env.MSOAUTH_AUTHORITY;
|
|
39
33
|
const redirectUri = process.env.MSOAUTH_REDIRECT_URI;
|
|
40
34
|
|
|
41
|
-
if (!authority) {
|
|
42
|
-
throw new Error(
|
|
43
|
-
"MSOAUTH_AUTHORITY is not set. Wire via CDK (infra/stacks/dev/stacks/remit-api-stack.ts) or set in localhost-dev-aws.env.",
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
35
|
if (!redirectUri) {
|
|
47
36
|
throw new Error(
|
|
48
|
-
"MSOAUTH_REDIRECT_URI is not set.
|
|
37
|
+
"MSOAUTH_REDIRECT_URI is not set. It is derived from PUBLIC_ORIGIN — set PUBLIC_ORIGIN in the deploy env file (deploy/vps/.env) and restart, or set MSOAUTH_REDIRECT_URI directly.",
|
|
49
38
|
);
|
|
50
39
|
}
|
|
51
40
|
|
|
52
41
|
return {
|
|
53
42
|
secretArn: process.env.MSOAUTH_SECRET_ARN,
|
|
54
|
-
authority,
|
|
55
43
|
redirectUri,
|
|
56
44
|
clientId: process.env.MSOAUTH_CLIENT_ID,
|
|
57
45
|
clientSecret: process.env.MSOAUTH_CLIENT_SECRET,
|
|
@@ -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 {
|
|
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
|
+
});
|
package/src/handlers/organize.ts
CHANGED
|
@@ -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({
|
package/src/service/organize.ts
CHANGED
|
@@ -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 =
|
|
450
|
+
const rejection = organizePredicateRejection(predicate);
|
|
438
451
|
if (rejection) return { rejected: rejection };
|
|
439
452
|
const messageIds = await matchLiteral(
|
|
440
453
|
deps,
|