@remit/backend 0.0.1
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/.env.test +7 -0
- package/dev-server/content-auth.test.ts +113 -0
- package/dev-server/content-auth.ts +54 -0
- package/dev-server/content-handler.test.ts +105 -0
- package/dev-server/content-handler.ts +79 -0
- package/dev-server/content-path.test.ts +37 -0
- package/dev-server/content-path.ts +27 -0
- package/dev-server/cors.test.ts +48 -0
- package/dev-server/cors.ts +25 -0
- package/dev-server/lambda-helpers.ts +69 -0
- package/dev-server/server.ts +289 -0
- package/package.json +82 -0
- package/src/auth.ts +92 -0
- package/src/config/msoauth.ts +60 -0
- package/src/data-backend.test.ts +25 -0
- package/src/data-backend.ts +20 -0
- package/src/derive/autoMoved.ts +28 -0
- package/src/derive/contentSignature.test.ts +153 -0
- package/src/derive/contentSignature.ts +139 -0
- package/src/derive/contentUrl.test.ts +156 -0
- package/src/derive/contentUrl.ts +70 -0
- package/src/derive/enrichThreadRows.ts +155 -0
- package/src/derive/filterThreadCriteria.test.ts +119 -0
- package/src/derive/filterThreadCriteria.ts +60 -0
- package/src/derive/pendingMoveCounts.ts +90 -0
- package/src/derive/senderTrust.test.ts +67 -0
- package/src/derive/senderTrust.ts +23 -0
- package/src/error.ts +55 -0
- package/src/handlers/account-guards.ts +137 -0
- package/src/handlers/account-oauth.test.ts +383 -0
- package/src/handlers/account-oauth.ts +452 -0
- package/src/handlers/account-overrides.test.ts +69 -0
- package/src/handlers/account-overrides.ts +286 -0
- package/src/handlers/account-ownership.ts +25 -0
- package/src/handlers/account-signature.ts +129 -0
- package/src/handlers/account.ts +524 -0
- package/src/handlers/address.ts +136 -0
- package/src/handlers/config.test.ts +184 -0
- package/src/handlers/config.ts +219 -0
- package/src/handlers/ensure-account-config.ts +30 -0
- package/src/handlers/filter.ts +294 -0
- package/src/handlers/folder-role-appointments.test.ts +250 -0
- package/src/handlers/folder-role-appointments.ts +272 -0
- package/src/handlers/folder-role.ts +76 -0
- package/src/handlers/index.ts +48 -0
- package/src/handlers/mailbox.ts +411 -0
- package/src/handlers/me.ts +190 -0
- package/src/handlers/message.ts +886 -0
- package/src/handlers/organize.test.ts +43 -0
- package/src/handlers/organize.ts +196 -0
- package/src/handlers/outbox.ts +218 -0
- package/src/handlers/search.test.ts +182 -0
- package/src/handlers/search.ts +105 -0
- package/src/handlers/sync-progress.test.ts +158 -0
- package/src/handlers/sync-progress.ts +64 -0
- package/src/handlers/sync.test.ts +146 -0
- package/src/handlers/sync.ts +119 -0
- package/src/handlers/thread.ts +361 -0
- package/src/handlers/unified-threads.ts +196 -0
- package/src/handlers/vip-suggestions.ts +21 -0
- package/src/index.ts +170 -0
- package/src/json.ts +11 -0
- package/src/jwt-auth.test.ts +89 -0
- package/src/jwt-auth.ts +95 -0
- package/src/request-context.test.ts +61 -0
- package/src/request-context.ts +29 -0
- package/src/request.ts +10 -0
- package/src/response.test.ts +107 -0
- package/src/response.ts +80 -0
- package/src/service/compose-postgres.ts +72 -0
- package/src/service/compose-sqlite.ts +80 -0
- package/src/service/create-remit-client.ts +358 -0
- package/src/service/dynamodb.test.ts +100 -0
- package/src/service/dynamodb.ts +58 -0
- package/src/service/filter.ts +55 -0
- package/src/service/fire-and-forget.test.ts +126 -0
- package/src/service/fire-and-forget.ts +79 -0
- package/src/service/localhost-env-config.test.ts +40 -0
- package/src/service/organize.test.ts +384 -0
- package/src/service/organize.ts +354 -0
- package/src/service/semantic-capability.test.ts +64 -0
- package/src/service/semantic-capability.ts +62 -0
- package/src/service/sqs.ts +4 -0
- package/src/service/trigger-sync.test.ts +211 -0
- package/src/service/trigger-sync.ts +102 -0
- package/src/types.ts +161 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { isStorageNotFoundError } from "@remit/storage-service";
|
|
4
|
+
import type { APIGatewayProxyResult } from "aws-lambda";
|
|
5
|
+
import { env } from "expect-env";
|
|
6
|
+
import express, {
|
|
7
|
+
type NextFunction,
|
|
8
|
+
type Request,
|
|
9
|
+
type Response,
|
|
10
|
+
} from "express";
|
|
11
|
+
import { handler, OpenAPISpec } from "../src/index.js";
|
|
12
|
+
import { safeJsonParse } from "../src/json.js";
|
|
13
|
+
import { getClient } from "../src/service/dynamodb.js";
|
|
14
|
+
import { authorizeContentRequest } from "./content-auth.js";
|
|
15
|
+
import { serveContent } from "./content-handler.js";
|
|
16
|
+
import { resolveContentPath } from "./content-path.js";
|
|
17
|
+
import { parseAllowedOrigins, resolveAllowOrigin } from "./cors.js";
|
|
18
|
+
import { createLambdaContext, createLambdaEvent } from "./lambda-helpers.js";
|
|
19
|
+
|
|
20
|
+
const app = express();
|
|
21
|
+
|
|
22
|
+
// The self-host relational backends — Postgres and, from RFC 036, SQLite — both
|
|
23
|
+
// run better-auth and the APISIX edge; the AWS-local (DynamoDB) dev path runs
|
|
24
|
+
// neither. Everything gated on "not the AWS-local path" keys off this.
|
|
25
|
+
const isSelfHostBackend =
|
|
26
|
+
process.env.DATA_BACKEND === "postgres" ||
|
|
27
|
+
process.env.DATA_BACKEND === "sqlite";
|
|
28
|
+
|
|
29
|
+
// CORS is driven by CORS_ALLOWED_ORIGINS (comma-separated, or `*`). On the
|
|
30
|
+
// self-host backends it is required config — refuse to start if unset, so the
|
|
31
|
+
// deployed edge is never accidentally wide open by omission. On the AWS-local
|
|
32
|
+
// dev path it defaults to `*` to keep the existing local flow working.
|
|
33
|
+
const configuredCorsOrigins = parseAllowedOrigins(
|
|
34
|
+
process.env.CORS_ALLOWED_ORIGINS,
|
|
35
|
+
);
|
|
36
|
+
if (isSelfHostBackend && configuredCorsOrigins.length === 0) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
"Missing required env var CORS_ALLOWED_ORIGINS (comma-separated origins, or '*')",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const corsAllowedOrigins =
|
|
42
|
+
configuredCorsOrigins.length > 0 ? configuredCorsOrigins : ["*"];
|
|
43
|
+
|
|
44
|
+
// better-auth owns identity on the self-host backends. Its handler must be
|
|
45
|
+
// mounted BEFORE express.json() (better-auth reads the raw body itself; a prior
|
|
46
|
+
// json parser leaves its fetch calls hanging). This mirrors the production edge
|
|
47
|
+
// where the same better-auth service sits in front of the API.
|
|
48
|
+
if (isSelfHostBackend) {
|
|
49
|
+
// Synthetic OIDC discovery document for the APISIX edge tier. better-auth
|
|
50
|
+
// serves a JWKS but not a discovery doc; APISIX's openid-connect plugin needs
|
|
51
|
+
// one to locate the JWKS. Registered before the better-auth catch-all so it
|
|
52
|
+
// is not shadowed. `issuer` matches the token `iss`, so it stays the public
|
|
53
|
+
// BETTER_AUTH_URL. `jwks_uri` is different: APISIX fetches it directly, and
|
|
54
|
+
// on a deployment where the public origin isn't reachable from inside the
|
|
55
|
+
// container network (a tailnet/VPN address a bridge network can't route to
|
|
56
|
+
// — the shape deploy/vps's compose stack runs in), deriving it from
|
|
57
|
+
// BETTER_AUTH_URL would point APISIX at an address it cannot reach, even
|
|
58
|
+
// though it just reached this very endpoint fine.
|
|
59
|
+
//
|
|
60
|
+
// BETTER_AUTH_JWKS_URL — the same in-network override
|
|
61
|
+
// remit-auth-service's own verifier config already reads
|
|
62
|
+
// (packages/auth-service/src/config.ts) and every env file sets to
|
|
63
|
+
// the backend's in-network address — is authoritative here, not the
|
|
64
|
+
// incoming request's Host header: an operator-controlled value instead
|
|
65
|
+
// of one derived from client-supplied input. It only falls back to
|
|
66
|
+
// echoing the request's own host when unset, matching prior behavior
|
|
67
|
+
// for any environment that doesn't set it.
|
|
68
|
+
app.get(
|
|
69
|
+
"/api/auth/.well-known/openid-configuration",
|
|
70
|
+
(req: Request, res: Response) => {
|
|
71
|
+
const base =
|
|
72
|
+
process.env.BETTER_AUTH_URL ??
|
|
73
|
+
`http://localhost:${process.env.SERVER_PORT ?? "5436"}`;
|
|
74
|
+
const jwksUri =
|
|
75
|
+
process.env.BETTER_AUTH_JWKS_URL ??
|
|
76
|
+
`${req.protocol}://${req.get("host")}/api/auth/jwks`;
|
|
77
|
+
res.json({
|
|
78
|
+
issuer: base,
|
|
79
|
+
jwks_uri: jwksUri,
|
|
80
|
+
authorization_endpoint: `${base}/api/auth/authorize`,
|
|
81
|
+
token_endpoint: `${base}/api/auth/token`,
|
|
82
|
+
response_types_supported: ["token"],
|
|
83
|
+
subject_types_supported: ["public"],
|
|
84
|
+
id_token_signing_alg_values_supported: ["RS256"],
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const { createAuth, resolveAuthConfig, toNodeHandler } = await import(
|
|
90
|
+
"@remit/auth-service"
|
|
91
|
+
);
|
|
92
|
+
const auth = await createAuth(resolveAuthConfig());
|
|
93
|
+
app.all(/^\/api\/auth\//, toNodeHandler(auth));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
app.use(express.json({ limit: "10mb" }));
|
|
97
|
+
app.use(express.urlencoded({ extended: true }));
|
|
98
|
+
|
|
99
|
+
app.use((req: Request, res: Response, next: NextFunction) => {
|
|
100
|
+
const allowOrigin = resolveAllowOrigin(
|
|
101
|
+
req.headers.origin,
|
|
102
|
+
corsAllowedOrigins,
|
|
103
|
+
);
|
|
104
|
+
if (allowOrigin) {
|
|
105
|
+
res.header("Access-Control-Allow-Origin", allowOrigin);
|
|
106
|
+
if (allowOrigin !== "*") res.header("Vary", "Origin");
|
|
107
|
+
}
|
|
108
|
+
res.header(
|
|
109
|
+
"Access-Control-Allow-Methods",
|
|
110
|
+
"GET, POST, PUT, DELETE, OPTIONS, PATCH",
|
|
111
|
+
);
|
|
112
|
+
res.header(
|
|
113
|
+
"Access-Control-Allow-Headers",
|
|
114
|
+
"Origin, X-Requested-With, Content-Type, Accept, Authorization",
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
if (req.method === "OPTIONS") {
|
|
118
|
+
res.sendStatus(200);
|
|
119
|
+
} else {
|
|
120
|
+
next();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
app.get("/.well-known/appspecific/com.chrome.devtools.json", () => ({}));
|
|
125
|
+
|
|
126
|
+
app.get("/health", (_req: Request, res: Response) => {
|
|
127
|
+
res.json({
|
|
128
|
+
status: "ok",
|
|
129
|
+
timestamp: new Date().toISOString(),
|
|
130
|
+
service: "remit-backend-local",
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Swagger UI exposes the full API schema, so it must never be on the public
|
|
135
|
+
// surface. On the self-host backends this server is the deployed backend
|
|
136
|
+
// container; gate the docs to the AWS-local dev path only. The generated
|
|
137
|
+
// OpenAPI document is still consumed programmatically by the APISIX edge via its
|
|
138
|
+
// own mounted paths — this only removes the browsable UI from the deployed
|
|
139
|
+
// surface.
|
|
140
|
+
//
|
|
141
|
+
// Dynamic import, not a static one: swagger-ui-express uses `__dirname` to
|
|
142
|
+
// locate its bundled assets on disk, which esbuild only shims (not
|
|
143
|
+
// eliminates) for an unconditional top-level import — that shim plus this
|
|
144
|
+
// file's own top-level `await import("@remit/auth-service")` above
|
|
145
|
+
// makes the bundle's module format ambiguous to Node
|
|
146
|
+
// (`ERR_AMBIGUOUS_MODULE_SYNTAX`), crashing every backend container start,
|
|
147
|
+
// including the ones that never take this branch. A dynamic import here
|
|
148
|
+
// keeps swagger-ui-express (and its `__dirname` shim) out of module scope
|
|
149
|
+
// entirely unless this is the AWS-local dev path.
|
|
150
|
+
if (!isSelfHostBackend) {
|
|
151
|
+
const swaggerUi = await import("swagger-ui-express");
|
|
152
|
+
const localSpec = { ...OpenAPISpec };
|
|
153
|
+
if (localSpec.servers) {
|
|
154
|
+
localSpec.servers[0].url = "/";
|
|
155
|
+
}
|
|
156
|
+
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(localSpec));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Local stand-in for the CloudFront `/content/*` behavior. In production the
|
|
160
|
+
// Lambda@Edge JWT verifier guards these requests and CloudFront serves them
|
|
161
|
+
// from the storage S3 bucket via OAC. Locally we stream the bytes from the
|
|
162
|
+
// filesystem-backed storage location keyed by URL path. No JWT check — this
|
|
163
|
+
// server only runs against an isolated dev/e2e DynamoDB + storage tree.
|
|
164
|
+
//
|
|
165
|
+
// `LOCAL_CONTENT_STORAGE_BASE` lets the e2e/smoke fixture point this server at
|
|
166
|
+
// the same absolute filesystem root it seeded under (repo-relative), since
|
|
167
|
+
// the fixture writes from the repo root while the dev-server's cwd is the
|
|
168
|
+
// playwright workspace.
|
|
169
|
+
const STORAGE_LOCAL_PATH = process.env.STORAGE_LOCAL_PATH ?? ".remit/storage";
|
|
170
|
+
const STORAGE_BASE = resolve(
|
|
171
|
+
process.env.LOCAL_CONTENT_STORAGE_BASE ?? process.cwd(),
|
|
172
|
+
STORAGE_LOCAL_PATH,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
app.get(/^\/content\/.+$/, async (req: Request, res: Response) => {
|
|
176
|
+
const storageKey = req.path.replace(/^\/content\//, "");
|
|
177
|
+
|
|
178
|
+
// On the Postgres stack this route is the deployed content-delivery surface;
|
|
179
|
+
// require a valid signed URL (HMAC + expiry, scoped to the owning account)
|
|
180
|
+
// before touching storage. Bearer auth can't ride on an `<img src>` content
|
|
181
|
+
// load, so the signature carried in the query string is the authorization.
|
|
182
|
+
const auth = authorizeContentRequest({
|
|
183
|
+
dataBackend: process.env.DATA_BACKEND,
|
|
184
|
+
secret: process.env.BETTER_AUTH_SECRET,
|
|
185
|
+
relativePath: storageKey,
|
|
186
|
+
exp: typeof req.query.exp === "string" ? req.query.exp : undefined,
|
|
187
|
+
sig: typeof req.query.sig === "string" ? req.query.sig : undefined,
|
|
188
|
+
nowSeconds: Math.floor(Date.now() / 1000),
|
|
189
|
+
});
|
|
190
|
+
if (!auth.authorized) {
|
|
191
|
+
res.status(auth.status).setHeader("x-remit-403-reason", auth.reason);
|
|
192
|
+
res.send(auth.reason);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const fullPath = resolveContentPath(STORAGE_BASE, storageKey);
|
|
197
|
+
if (fullPath === null) {
|
|
198
|
+
res.status(400).send("invalid path");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const client = await getClient();
|
|
203
|
+
const result = await serveContent(
|
|
204
|
+
{
|
|
205
|
+
// ENOENT means the body isn't synced yet (→ 202); any other read error
|
|
206
|
+
// throws so express 500s it — never masked as a missing body.
|
|
207
|
+
readObject: async (path) =>
|
|
208
|
+
readFile(path).catch((error: unknown) => {
|
|
209
|
+
if (isStorageNotFoundError(error)) return null;
|
|
210
|
+
throw error;
|
|
211
|
+
}),
|
|
212
|
+
lookupMessage: async (messageId) => {
|
|
213
|
+
// A gone message row means nothing to re-arm — still answer 202.
|
|
214
|
+
const message = await client.message.get(messageId).catch(() => null);
|
|
215
|
+
return message
|
|
216
|
+
? { mailboxId: message.mailboxId, uid: message.uid }
|
|
217
|
+
: null;
|
|
218
|
+
},
|
|
219
|
+
requestBodySync: async (input) => {
|
|
220
|
+
await client.bodySyncQueue?.requestBodySync(input);
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
{ fullPath, storageKey },
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
res.status(result.status);
|
|
227
|
+
for (const [name, value] of Object.entries(result.headers)) {
|
|
228
|
+
res.setHeader(name, value);
|
|
229
|
+
}
|
|
230
|
+
res.send(result.body);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
app.all(/(.*)/, async (req: Request, res: Response) => {
|
|
234
|
+
const event = createLambdaEvent(req);
|
|
235
|
+
const context = createLambdaContext();
|
|
236
|
+
|
|
237
|
+
const result: APIGatewayProxyResult = await handler(event, context);
|
|
238
|
+
const headers = (result.headers ?? {}) as Record<string, string>;
|
|
239
|
+
|
|
240
|
+
let body: unknown = result.body;
|
|
241
|
+
|
|
242
|
+
if (
|
|
243
|
+
typeof body === "string" &&
|
|
244
|
+
headers["Content-Type"]?.includes("application/json")
|
|
245
|
+
) {
|
|
246
|
+
const parsed = await safeJsonParse<unknown>(body).catch(() => undefined);
|
|
247
|
+
if (parsed === undefined) {
|
|
248
|
+
console.error("[dev-server] Failed to parse JSON body");
|
|
249
|
+
} else if (
|
|
250
|
+
parsed &&
|
|
251
|
+
typeof parsed === "object" &&
|
|
252
|
+
"statusCode" in parsed &&
|
|
253
|
+
"body" in parsed
|
|
254
|
+
) {
|
|
255
|
+
const inner = (parsed as { body: unknown }).body;
|
|
256
|
+
body =
|
|
257
|
+
typeof inner === "string"
|
|
258
|
+
? await safeJsonParse(inner).catch(() => inner)
|
|
259
|
+
: inner;
|
|
260
|
+
} else {
|
|
261
|
+
body = parsed;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
res
|
|
266
|
+
.setHeaders(new Map(Object.entries(headers)))
|
|
267
|
+
.status(result.statusCode)
|
|
268
|
+
.send(body);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const port = env.SERVER_PORT;
|
|
272
|
+
|
|
273
|
+
app.listen(Number(port), "0.0.0.0", () => {
|
|
274
|
+
console.log(`Remit Backend running on http://localhost:${port}`);
|
|
275
|
+
console.log(
|
|
276
|
+
`OpenAPI documentation available at http://localhost:${port}/api-docs`,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
console.table({
|
|
280
|
+
SERVER_PORT: port,
|
|
281
|
+
DYNAMODB_PORT: env.DYNAMODB_PORT,
|
|
282
|
+
DYNAMODB_TABLE: env.DYNAMODB_TABLE_NAME,
|
|
283
|
+
NODE_ENV: env.NODE_ENV,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
process.send?.("ready");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
export default app;
|
package/package.json
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@remit/backend",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Remit Mail Inspector API backend",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/index.ts",
|
|
12
|
+
"default": "./src/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"./client": {
|
|
15
|
+
"types": "./src/service/dynamodb.ts",
|
|
16
|
+
"default": "./src/service/dynamodb.ts"
|
|
17
|
+
},
|
|
18
|
+
"./organize": {
|
|
19
|
+
"types": "./src/service/organize.ts",
|
|
20
|
+
"default": "./src/service/organize.ts"
|
|
21
|
+
},
|
|
22
|
+
"./trigger-sync": {
|
|
23
|
+
"types": "./src/service/trigger-sync.ts",
|
|
24
|
+
"default": "./src/service/trigger-sync.ts"
|
|
25
|
+
},
|
|
26
|
+
"./content-handler": {
|
|
27
|
+
"types": "./dev-server/content-handler.ts",
|
|
28
|
+
"default": "./dev-server/content-handler.ts"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test:typecheck": "tsgo --noEmit",
|
|
33
|
+
"test:run": "node --env-file .env.test --import tsx --test 'src/**/*.test.ts' 'dev-server/**/*.test.ts'",
|
|
34
|
+
"test": "npm run test:typecheck && npm run test:run",
|
|
35
|
+
"bundle": "esbuild 'src/index.ts' --sourcemap --bundle --platform=node --outfile=dist/index.js",
|
|
36
|
+
"dev": "node --env-file .env.test --watch --import tsx dev-server/server.ts",
|
|
37
|
+
"dev:remote": "node --env-file ../../.env.dev --watch --import tsx dev-server/server.ts"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@aws-sdk/client-secrets-manager": "*",
|
|
41
|
+
"@aws-sdk/client-sqs": "*",
|
|
42
|
+
"@aws-sdk/core": "*",
|
|
43
|
+
"@remit/data-ports": "*",
|
|
44
|
+
"@remit/electrodb-entities": "*",
|
|
45
|
+
"@remit/auth-service": "*",
|
|
46
|
+
"@remit/drizzle-service": "*",
|
|
47
|
+
"drizzle-orm": "^0.45.2",
|
|
48
|
+
"@remit/domain-enums": "*",
|
|
49
|
+
"@remit/mail-oauth-service": "*",
|
|
50
|
+
"@remit/mailbox-service": "*",
|
|
51
|
+
"@remit/secrets-service": "*",
|
|
52
|
+
"@remit/api-openapi-types": "*",
|
|
53
|
+
"@remit/search-index-worker": "*",
|
|
54
|
+
"@remit/search-service": "*",
|
|
55
|
+
"@remit/sqs-client": "*",
|
|
56
|
+
"@remit/storage-service": "*",
|
|
57
|
+
"@types/aws-lambda": "*",
|
|
58
|
+
"@types/express": "^5.0.3",
|
|
59
|
+
"@types/swagger-ui-express": "^4.1.8",
|
|
60
|
+
"electrodb": "*",
|
|
61
|
+
"esbuild": "^0.27.7",
|
|
62
|
+
"expect-env": "*",
|
|
63
|
+
"express": "^5.1.0",
|
|
64
|
+
"openapi-backend": "*",
|
|
65
|
+
"p-map": "*",
|
|
66
|
+
"@remit/logger-lambda": "*",
|
|
67
|
+
"swagger-ui-express": "^5.0.1",
|
|
68
|
+
"tsx": "*"
|
|
69
|
+
},
|
|
70
|
+
"dependencies": {
|
|
71
|
+
"jose": "^6.2.3",
|
|
72
|
+
"mailparser": "^3.9.14"
|
|
73
|
+
},
|
|
74
|
+
"publishConfig": {
|
|
75
|
+
"access": "public"
|
|
76
|
+
},
|
|
77
|
+
"repository": {
|
|
78
|
+
"type": "git",
|
|
79
|
+
"url": "git+https://github.com/remit-mail/remit.git",
|
|
80
|
+
"directory": "packages/backend"
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { base36uuidv5, REMIT_NAMESPACE } from "@remit/data-ports/id";
|
|
2
|
+
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
3
|
+
|
|
4
|
+
const LOCAL_BYPASS_VARS = [
|
|
5
|
+
"LOCAL_ACCOUNT_CONFIG_ID",
|
|
6
|
+
"LOCAL_COGNITO_SUB",
|
|
7
|
+
] as const;
|
|
8
|
+
|
|
9
|
+
const isLocalEnv = (env: NodeJS.ProcessEnv): boolean =>
|
|
10
|
+
env.NODE_ENV === "development" || env.NODE_ENV === "test";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The LOCAL_ACCOUNT_CONFIG_ID / LOCAL_COGNITO_SUB overrides bypass authentication
|
|
14
|
+
* for local dev and fixtures: with them set, an unauthenticated request is served
|
|
15
|
+
* as the pinned account. They must never reach a deployed environment. Refuse to
|
|
16
|
+
* start (throw at module load) if either is present outside a local NODE_ENV — the
|
|
17
|
+
* same `development`/`test` signal the data layer already uses to decide local vs
|
|
18
|
+
* deployed. Fail loud rather than silently ignore, so a leaked env var is caught at
|
|
19
|
+
* boot, not exploited at runtime.
|
|
20
|
+
*/
|
|
21
|
+
export const assertLocalBypassNotInDeployedEnv = (
|
|
22
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
23
|
+
): void => {
|
|
24
|
+
if (isLocalEnv(env)) return;
|
|
25
|
+
const leaked = LOCAL_BYPASS_VARS.filter((name) => {
|
|
26
|
+
const value = env[name];
|
|
27
|
+
return typeof value === "string" && value.length > 0;
|
|
28
|
+
});
|
|
29
|
+
if (leaked.length === 0) return;
|
|
30
|
+
throw new Error(
|
|
31
|
+
`Refusing to start: local auth bypass ${leaked.join(", ")} is set while NODE_ENV=${env.NODE_ENV ?? "(unset)"}. These overrides bypass authentication and must never be present in a deployed environment.`,
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Derive a deterministic accountConfigId from a Cognito subject (`sub`) claim.
|
|
37
|
+
* One Cognito user maps to exactly one account config.
|
|
38
|
+
*/
|
|
39
|
+
export const deriveAccountConfigId = (sub: string): string =>
|
|
40
|
+
base36uuidv5(`account:${sub}`, REMIT_NAMESPACE);
|
|
41
|
+
|
|
42
|
+
const getSubFromClaims = (event: APIGatewayProxyEvent): string | undefined => {
|
|
43
|
+
const claims = event.requestContext?.authorizer?.claims;
|
|
44
|
+
if (!claims) return undefined;
|
|
45
|
+
const sub = claims.sub;
|
|
46
|
+
if (typeof sub === "string" && sub.length > 0) return sub;
|
|
47
|
+
return undefined;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Return the Cognito `sub` for the current request, preferring the JWT claim
|
|
52
|
+
* and falling back to `LOCAL_COGNITO_SUB` for local development. Returns
|
|
53
|
+
* `undefined` when neither is available (e.g. seeded fixtures that only set
|
|
54
|
+
* `LOCAL_ACCOUNT_CONFIG_ID`).
|
|
55
|
+
*/
|
|
56
|
+
export const getSubFromEvent = (
|
|
57
|
+
event: APIGatewayProxyEvent,
|
|
58
|
+
): string | undefined => {
|
|
59
|
+
const sub = getSubFromClaims(event);
|
|
60
|
+
if (sub) return sub;
|
|
61
|
+
const localSub = process.env.LOCAL_COGNITO_SUB;
|
|
62
|
+
if (localSub && localSub.length > 0) return localSub;
|
|
63
|
+
return undefined;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Extract the caller's accountConfigId from an API Gateway event.
|
|
68
|
+
*
|
|
69
|
+
* Production: reads the Cognito `sub` claim (verified by the API Gateway
|
|
70
|
+
* Cognito authorizer) and derives the accountConfigId via UUIDv5.
|
|
71
|
+
*
|
|
72
|
+
* Local development: honours two environment overrides so the dev server and
|
|
73
|
+
* playwright fixtures can run without a real Cognito token:
|
|
74
|
+
* - `LOCAL_ACCOUNT_CONFIG_ID` — pin a specific seeded accountConfigId
|
|
75
|
+
* - `LOCAL_COGNITO_SUB` — pin a Cognito `sub` value; derived like production
|
|
76
|
+
*/
|
|
77
|
+
export const getAccountConfigIdFromEvent = (
|
|
78
|
+
event: APIGatewayProxyEvent,
|
|
79
|
+
): string => {
|
|
80
|
+
const sub = getSubFromClaims(event);
|
|
81
|
+
if (sub) return deriveAccountConfigId(sub);
|
|
82
|
+
|
|
83
|
+
const localAccountConfigId = process.env.LOCAL_ACCOUNT_CONFIG_ID;
|
|
84
|
+
if (localAccountConfigId) return localAccountConfigId;
|
|
85
|
+
|
|
86
|
+
const localSub = process.env.LOCAL_COGNITO_SUB;
|
|
87
|
+
if (localSub) return deriveAccountConfigId(localSub);
|
|
88
|
+
|
|
89
|
+
throw new Error(
|
|
90
|
+
"Missing accountConfigId: no Cognito `sub` claim on the request, and neither LOCAL_ACCOUNT_CONFIG_ID nor LOCAL_COGNITO_SUB env var is set",
|
|
91
|
+
);
|
|
92
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed reads for Microsoft OAuth (Entra) environment variables.
|
|
3
|
+
*
|
|
4
|
+
* In production these are wired by CDK (see infra/stacks/dev/stacks/):
|
|
5
|
+
* - `MSOAUTH_SECRET_ARN` — Secrets Manager ARN; Lambda calls GetSecretValue
|
|
6
|
+
* at runtime to retrieve `{"clientId":"…","clientSecret":"…"}`.
|
|
7
|
+
* - `MSOAUTH_AUTHORITY` — OIDC authority base URL (baked in at deploy time).
|
|
8
|
+
* - `MSOAUTH_REDIRECT_URI`— OAuth callback URI (baked in at deploy time).
|
|
9
|
+
*
|
|
10
|
+
* For local development, set `MSOAUTH_CLIENT_ID` and `MSOAUTH_CLIENT_SECRET`
|
|
11
|
+
* directly in `localhost-dev-aws.env` to bypass Secrets Manager.
|
|
12
|
+
*
|
|
13
|
+
* See doc/oauth-microsoft.md for the Azure portal setup runbook.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface MsOAuthConfig {
|
|
17
|
+
/** Secrets Manager ARN — present in deployed Lambda environments. */
|
|
18
|
+
readonly secretArn: string | undefined;
|
|
19
|
+
/** OIDC authority, e.g. `https://login.microsoftonline.com/common`. */
|
|
20
|
+
readonly authority: string;
|
|
21
|
+
/** OAuth redirect URI registered in the Entra app. */
|
|
22
|
+
readonly redirectUri: string;
|
|
23
|
+
/** Client ID — used for local dev (bypasses Secrets Manager). */
|
|
24
|
+
readonly clientId: string | undefined;
|
|
25
|
+
/** Client secret — used for local dev (bypasses Secrets Manager). */
|
|
26
|
+
readonly clientSecret: string | undefined;
|
|
27
|
+
/** Token endpoint override — used for local stubbing (bypasses default OIDC discovery). */
|
|
28
|
+
readonly tokenEndpoint: string | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read Microsoft OAuth configuration from the environment.
|
|
33
|
+
* Throws when mandatory values (`MSOAUTH_AUTHORITY`, `MSOAUTH_REDIRECT_URI`)
|
|
34
|
+
* are absent — both are always present in deployed Lambdas and should be set
|
|
35
|
+
* in `localhost-dev-aws.env` for local development.
|
|
36
|
+
*/
|
|
37
|
+
export const getMsOAuthConfig = (): MsOAuthConfig => {
|
|
38
|
+
const authority = process.env.MSOAUTH_AUTHORITY;
|
|
39
|
+
const redirectUri = process.env.MSOAUTH_REDIRECT_URI;
|
|
40
|
+
|
|
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
|
+
if (!redirectUri) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
"MSOAUTH_REDIRECT_URI is not set. Wire via CDK (infra/stacks/dev/stacks/remit-api-stack.ts) or set in localhost-dev-aws.env.",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
secretArn: process.env.MSOAUTH_SECRET_ARN,
|
|
54
|
+
authority,
|
|
55
|
+
redirectUri,
|
|
56
|
+
clientId: process.env.MSOAUTH_CLIENT_ID,
|
|
57
|
+
clientSecret: process.env.MSOAUTH_CLIENT_SECRET,
|
|
58
|
+
tokenEndpoint: process.env.MSOAUTH_TOKEN_ENDPOINT,
|
|
59
|
+
};
|
|
60
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, describe, it } from "node:test";
|
|
3
|
+
import { usesBetterAuthJwt } from "./data-backend.js";
|
|
4
|
+
|
|
5
|
+
describe("usesBetterAuthJwt", () => {
|
|
6
|
+
const ORIGINAL = process.env.DATA_BACKEND;
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
if (ORIGINAL === undefined) delete process.env.DATA_BACKEND;
|
|
9
|
+
else process.env.DATA_BACKEND = ORIGINAL;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("is true for the self-host SQL backends", () => {
|
|
13
|
+
process.env.DATA_BACKEND = "postgres";
|
|
14
|
+
assert.equal(usesBetterAuthJwt(), true);
|
|
15
|
+
process.env.DATA_BACKEND = "sqlite";
|
|
16
|
+
assert.equal(usesBetterAuthJwt(), true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("is false for the AWS DynamoDB path and when unset", () => {
|
|
20
|
+
process.env.DATA_BACKEND = "dynamodb";
|
|
21
|
+
assert.equal(usesBetterAuthJwt(), false);
|
|
22
|
+
delete process.env.DATA_BACKEND;
|
|
23
|
+
assert.equal(usesBetterAuthJwt(), false);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two self-host SQL backends (RFC 034/035/036), as opposed to the AWS
|
|
3
|
+
* DynamoDB path.
|
|
4
|
+
*/
|
|
5
|
+
export const isSelfHostSqlBackend = (): boolean => {
|
|
6
|
+
const backend = process.env.DATA_BACKEND;
|
|
7
|
+
return backend === "postgres" || backend === "sqlite";
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The self-host SQL backends authenticate requests and sign content URLs the
|
|
12
|
+
* same way: a better-auth RS256 JWT verified at the edge and re-verified
|
|
13
|
+
* in-process, no Cognito authorizer. DynamoDB is the AWS path (Cognito claims,
|
|
14
|
+
* Lambda@Edge content guard) and is deliberately excluded.
|
|
15
|
+
*
|
|
16
|
+
* Guarding these paths on `=== "postgres"` alone left the sqlite deployment
|
|
17
|
+
* with no claim injection (every identity-bound request 500s) and unsigned
|
|
18
|
+
* `/content/*` URLs; both branch on this predicate instead.
|
|
19
|
+
*/
|
|
20
|
+
export const usesBetterAuthJwt = (): boolean => isSelfHostSqlBackend();
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AutoMovedInfo } from "@remit/api-openapi-types";
|
|
2
|
+
import type { MessageItem } from "@remit/data-ports";
|
|
3
|
+
import { PlacementConfidence } from "@remit/domain-enums";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Project a Message's internal placement verdict to the public `AutoMovedInfo`
|
|
7
|
+
* shape. Present only for a real, in-effect auto-move — `movedByRemit` is
|
|
8
|
+
* true, the verdict is `Confident`, and it was not a dry run. Confidence,
|
|
9
|
+
* dryRun, decidedAt and reasons are internal diagnostics and never cross to
|
|
10
|
+
* the wire; the client derives everything else (whether the move is still in
|
|
11
|
+
* effect, the undo target) from `action`/`fromPlacement` plus current
|
|
12
|
+
* placement.
|
|
13
|
+
*/
|
|
14
|
+
export const deriveAutoMoved = (
|
|
15
|
+
message: Pick<MessageItem, "movedByRemit" | "placementVerdict">,
|
|
16
|
+
): AutoMovedInfo | undefined => {
|
|
17
|
+
if (message.movedByRemit !== true) return undefined;
|
|
18
|
+
|
|
19
|
+
const verdict = message.placementVerdict;
|
|
20
|
+
if (!verdict) return undefined;
|
|
21
|
+
if (verdict.confidence !== PlacementConfidence.Confident) return undefined;
|
|
22
|
+
if (verdict.dryRun === true) return undefined;
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
action: verdict.action,
|
|
26
|
+
fromPlacement: verdict.fromPlacement,
|
|
27
|
+
};
|
|
28
|
+
};
|