@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.
Files changed (87) hide show
  1. package/.env.test +7 -0
  2. package/dev-server/content-auth.test.ts +113 -0
  3. package/dev-server/content-auth.ts +54 -0
  4. package/dev-server/content-handler.test.ts +105 -0
  5. package/dev-server/content-handler.ts +79 -0
  6. package/dev-server/content-path.test.ts +37 -0
  7. package/dev-server/content-path.ts +27 -0
  8. package/dev-server/cors.test.ts +48 -0
  9. package/dev-server/cors.ts +25 -0
  10. package/dev-server/lambda-helpers.ts +69 -0
  11. package/dev-server/server.ts +289 -0
  12. package/package.json +82 -0
  13. package/src/auth.ts +92 -0
  14. package/src/config/msoauth.ts +60 -0
  15. package/src/data-backend.test.ts +25 -0
  16. package/src/data-backend.ts +20 -0
  17. package/src/derive/autoMoved.ts +28 -0
  18. package/src/derive/contentSignature.test.ts +153 -0
  19. package/src/derive/contentSignature.ts +139 -0
  20. package/src/derive/contentUrl.test.ts +156 -0
  21. package/src/derive/contentUrl.ts +70 -0
  22. package/src/derive/enrichThreadRows.ts +155 -0
  23. package/src/derive/filterThreadCriteria.test.ts +119 -0
  24. package/src/derive/filterThreadCriteria.ts +60 -0
  25. package/src/derive/pendingMoveCounts.ts +90 -0
  26. package/src/derive/senderTrust.test.ts +67 -0
  27. package/src/derive/senderTrust.ts +23 -0
  28. package/src/error.ts +55 -0
  29. package/src/handlers/account-guards.ts +137 -0
  30. package/src/handlers/account-oauth.test.ts +383 -0
  31. package/src/handlers/account-oauth.ts +452 -0
  32. package/src/handlers/account-overrides.test.ts +69 -0
  33. package/src/handlers/account-overrides.ts +286 -0
  34. package/src/handlers/account-ownership.ts +25 -0
  35. package/src/handlers/account-signature.ts +129 -0
  36. package/src/handlers/account.ts +524 -0
  37. package/src/handlers/address.ts +136 -0
  38. package/src/handlers/config.test.ts +184 -0
  39. package/src/handlers/config.ts +219 -0
  40. package/src/handlers/ensure-account-config.ts +30 -0
  41. package/src/handlers/filter.ts +294 -0
  42. package/src/handlers/folder-role-appointments.test.ts +250 -0
  43. package/src/handlers/folder-role-appointments.ts +272 -0
  44. package/src/handlers/folder-role.ts +76 -0
  45. package/src/handlers/index.ts +48 -0
  46. package/src/handlers/mailbox.ts +411 -0
  47. package/src/handlers/me.ts +190 -0
  48. package/src/handlers/message.ts +886 -0
  49. package/src/handlers/organize.test.ts +43 -0
  50. package/src/handlers/organize.ts +196 -0
  51. package/src/handlers/outbox.ts +218 -0
  52. package/src/handlers/search.test.ts +182 -0
  53. package/src/handlers/search.ts +105 -0
  54. package/src/handlers/sync-progress.test.ts +158 -0
  55. package/src/handlers/sync-progress.ts +64 -0
  56. package/src/handlers/sync.test.ts +146 -0
  57. package/src/handlers/sync.ts +119 -0
  58. package/src/handlers/thread.ts +361 -0
  59. package/src/handlers/unified-threads.ts +196 -0
  60. package/src/handlers/vip-suggestions.ts +21 -0
  61. package/src/index.ts +170 -0
  62. package/src/json.ts +11 -0
  63. package/src/jwt-auth.test.ts +89 -0
  64. package/src/jwt-auth.ts +95 -0
  65. package/src/request-context.test.ts +61 -0
  66. package/src/request-context.ts +29 -0
  67. package/src/request.ts +10 -0
  68. package/src/response.test.ts +107 -0
  69. package/src/response.ts +80 -0
  70. package/src/service/compose-postgres.ts +72 -0
  71. package/src/service/compose-sqlite.ts +80 -0
  72. package/src/service/create-remit-client.ts +358 -0
  73. package/src/service/dynamodb.test.ts +100 -0
  74. package/src/service/dynamodb.ts +58 -0
  75. package/src/service/filter.ts +55 -0
  76. package/src/service/fire-and-forget.test.ts +126 -0
  77. package/src/service/fire-and-forget.ts +79 -0
  78. package/src/service/localhost-env-config.test.ts +40 -0
  79. package/src/service/organize.test.ts +384 -0
  80. package/src/service/organize.ts +354 -0
  81. package/src/service/semantic-capability.test.ts +64 -0
  82. package/src/service/semantic-capability.ts +62 -0
  83. package/src/service/sqs.ts +4 -0
  84. package/src/service/trigger-sync.test.ts +211 -0
  85. package/src/service/trigger-sync.ts +102 -0
  86. package/src/types.ts +161 -0
  87. 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
+ };