@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
package/src/index.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { inspect } from "node:util";
|
|
5
|
+
import { logger, withTelemetry } from "@remit/logger-lambda";
|
|
6
|
+
import type { APIGatewayProxyEvent, Context } from "aws-lambda";
|
|
7
|
+
import {
|
|
8
|
+
type Document,
|
|
9
|
+
OpenAPIBackend,
|
|
10
|
+
type Context as OpenAPIContext,
|
|
11
|
+
type Request,
|
|
12
|
+
} from "openapi-backend";
|
|
13
|
+
import { assertLocalBypassNotInDeployedEnv } from "./auth.js";
|
|
14
|
+
import { usesBetterAuthJwt } from "./data-backend.js";
|
|
15
|
+
import { handleError } from "./error.js";
|
|
16
|
+
import { handlers } from "./handlers/index.js";
|
|
17
|
+
import { authenticatePostgresRequest } from "./jwt-auth.js";
|
|
18
|
+
import { normalizeRequest } from "./request.js";
|
|
19
|
+
import { runWithRequestContext } from "./request-context.js";
|
|
20
|
+
import { formatResponse, postResponseHandler } from "./response.js";
|
|
21
|
+
|
|
22
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
|
|
24
|
+
// Boot-time refusal: the local auth-bypass overrides must never be set in a
|
|
25
|
+
// deployed environment. Runs on module load so a leaked env var fails the
|
|
26
|
+
// process at startup rather than silently opening authentication at runtime.
|
|
27
|
+
assertLocalBypassNotInDeployedEnv();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Last-resort net for a leaked background rejection.
|
|
31
|
+
*
|
|
32
|
+
* Every fire-and-forget side effect is meant to be contained at its source (see
|
|
33
|
+
* service/fire-and-forget.ts), so reaching here is a bug — an uncontained
|
|
34
|
+
* `void promise` somewhere. The API process (dev-server / warm Lambda container)
|
|
35
|
+
* shares one event loop across requests, so without this handler such a leak
|
|
36
|
+
* would surface as a spurious 500 on whatever request was in flight. We log it
|
|
37
|
+
* LOUDLY with a distinct alert so it is observable, and deliberately do NOT exit
|
|
38
|
+
* — a stray background rejection must never take the API down or fail a request.
|
|
39
|
+
* (The worker process intentionally has no such net: there each message is its
|
|
40
|
+
* own invocation and a rejection should crash it.)
|
|
41
|
+
*/
|
|
42
|
+
const unhandledRejectionRegistered = Symbol.for(
|
|
43
|
+
"remit.backend.unhandledRejectionNet",
|
|
44
|
+
);
|
|
45
|
+
const globalProcess = process as unknown as Record<symbol, boolean>;
|
|
46
|
+
if (!globalProcess[unhandledRejectionRegistered]) {
|
|
47
|
+
globalProcess[unhandledRejectionRegistered] = true;
|
|
48
|
+
process.on("unhandledRejection", (reason: unknown) => {
|
|
49
|
+
logger.error(
|
|
50
|
+
{
|
|
51
|
+
alert: "unhandled_rejection",
|
|
52
|
+
errorName: (reason as { name?: string })?.name,
|
|
53
|
+
errorCode:
|
|
54
|
+
(reason as { Code?: string })?.Code ??
|
|
55
|
+
(reason as { code?: string })?.code,
|
|
56
|
+
error: inspect(reason),
|
|
57
|
+
},
|
|
58
|
+
"Unhandled promise rejection leaked into the API event loop (contained, request not failed)",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const loadOpenAPISpec = (): Document => {
|
|
64
|
+
// Bundled Lambda: infra's NodeJSArmFunction copies openapi.json into the
|
|
65
|
+
// bundle root next to the entrypoint via its `extraFiles` prop.
|
|
66
|
+
const bundledPath = join(__dirname, "openapi.json");
|
|
67
|
+
if (existsSync(bundledPath)) {
|
|
68
|
+
return JSON.parse(readFileSync(bundledPath, "utf-8"));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Dev (tsx / dev-server): read straight from the repo build tree.
|
|
72
|
+
const devPath = join(__dirname, "../../../build/remit-openapi3/openapi.json");
|
|
73
|
+
return JSON.parse(readFileSync(devPath, "utf-8"));
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const OpenAPISpec = loadOpenAPISpec();
|
|
77
|
+
|
|
78
|
+
const api = new OpenAPIBackend({
|
|
79
|
+
definition: OpenAPISpec,
|
|
80
|
+
quick: true,
|
|
81
|
+
// Coerce query/path params to their schema types. API Gateway delivers every
|
|
82
|
+
// query param as a string; handlers cast them to number/boolean (e.g. `limit`,
|
|
83
|
+
// `hasAttachment`) and forward them downstream. Without coercion a string
|
|
84
|
+
// `limit` reaches ElectroDB's `.go({ limit })` and DynamoDB rejects the
|
|
85
|
+
// numeric Limit field ("STRING_VALUE cannot be converted to Integer"), 500ing
|
|
86
|
+
// /addresses/search and /search/semantic.
|
|
87
|
+
coerceTypes: true,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
api.register("postResponseHandler", postResponseHandler);
|
|
91
|
+
|
|
92
|
+
api.register("validationFail", (c: OpenAPIContext, req: Request) => {
|
|
93
|
+
const operation = api.router.getOperation(c.operation?.operationId ?? "");
|
|
94
|
+
logger.error(
|
|
95
|
+
{
|
|
96
|
+
errors: c.validation.errors,
|
|
97
|
+
method: req.method,
|
|
98
|
+
path: req.path,
|
|
99
|
+
operation: c.operation?.operationId,
|
|
100
|
+
operationParameters: operation?.parameters,
|
|
101
|
+
validationContext: {
|
|
102
|
+
parsedRequest: c.request,
|
|
103
|
+
validationTarget: c.validation,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
"Validation failed",
|
|
107
|
+
);
|
|
108
|
+
return {
|
|
109
|
+
statusCode: 400,
|
|
110
|
+
body: {
|
|
111
|
+
message: "Invalid request",
|
|
112
|
+
errors: c.validation.errors,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
api.register("notFound", () => ({ statusCode: 404 }));
|
|
118
|
+
|
|
119
|
+
api.register("notImplemented", async (c: OpenAPIContext) => {
|
|
120
|
+
const { status, mock } = api.mockResponseForOperation(
|
|
121
|
+
c.operation.operationId as string,
|
|
122
|
+
);
|
|
123
|
+
return formatResponse(mock as Record<string, unknown>, status);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
api.register(handlers);
|
|
127
|
+
|
|
128
|
+
api.init();
|
|
129
|
+
|
|
130
|
+
export { api, OpenAPISpec };
|
|
131
|
+
|
|
132
|
+
const readOriginHeader = (
|
|
133
|
+
headers: APIGatewayProxyEvent["headers"],
|
|
134
|
+
): string | undefined => {
|
|
135
|
+
if (!headers) return undefined;
|
|
136
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
137
|
+
if (key.toLowerCase() === "origin" && typeof value === "string") {
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const rawHandler = async (event: APIGatewayProxyEvent, context: Context) => {
|
|
145
|
+
logger.setBindings({
|
|
146
|
+
requestId: context.awsRequestId,
|
|
147
|
+
path: event.path,
|
|
148
|
+
method: event.httpMethod,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
logger.debug(
|
|
152
|
+
{ method: event.httpMethod, path: event.path },
|
|
153
|
+
"Request received",
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const origin = readOriginHeader(event.headers);
|
|
157
|
+
|
|
158
|
+
if (usesBetterAuthJwt()) {
|
|
159
|
+
const denied = await authenticatePostgresRequest(event);
|
|
160
|
+
if (denied) return denied;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return runWithRequestContext({ origin }, () =>
|
|
164
|
+
api
|
|
165
|
+
.handleRequest(normalizeRequest(event), event, context)
|
|
166
|
+
.catch(handleError),
|
|
167
|
+
);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export const handler = withTelemetry(rawHandler);
|
package/src/json.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse JSON in a Promise so a malformed string surfaces as a rejection the
|
|
3
|
+
* caller can `.catch()`, instead of a synchronous throw that would need a block
|
|
4
|
+
* try/catch. Use only where an unparseable value is an expected, recoverable
|
|
5
|
+
* absence (e.g. a third-party token, a dev-server response body) — never to
|
|
6
|
+
* soften an internal contract, which must stay a fatal uncaught `JSON.parse`.
|
|
7
|
+
*/
|
|
8
|
+
export const safeJsonParse = <T>(raw: string): Promise<T> =>
|
|
9
|
+
new Promise((resolve) => {
|
|
10
|
+
resolve(JSON.parse(raw));
|
|
11
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, beforeEach, test } from "node:test";
|
|
3
|
+
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
4
|
+
import {
|
|
5
|
+
_setVerifierForTest,
|
|
6
|
+
authenticatePostgresRequest,
|
|
7
|
+
} from "./jwt-auth.js";
|
|
8
|
+
|
|
9
|
+
const buildEvent = (
|
|
10
|
+
overrides: Partial<APIGatewayProxyEvent> = {},
|
|
11
|
+
): APIGatewayProxyEvent =>
|
|
12
|
+
({
|
|
13
|
+
headers: {},
|
|
14
|
+
requestContext: {},
|
|
15
|
+
...overrides,
|
|
16
|
+
}) as APIGatewayProxyEvent;
|
|
17
|
+
|
|
18
|
+
let savedBypass: string | undefined;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
savedBypass = process.env.LOCAL_ACCOUNT_CONFIG_ID;
|
|
22
|
+
delete process.env.LOCAL_ACCOUNT_CONFIG_ID;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
_setVerifierForTest(null);
|
|
27
|
+
if (savedBypass === undefined) delete process.env.LOCAL_ACCOUNT_CONFIG_ID;
|
|
28
|
+
else process.env.LOCAL_ACCOUNT_CONFIG_ID = savedBypass;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("valid token injects verified sub into authorizer claims", async () => {
|
|
32
|
+
_setVerifierForTest(async () => ({ sub: "user-abc", email: "a@b.com" }));
|
|
33
|
+
const event = buildEvent({
|
|
34
|
+
headers: { Authorization: "Bearer good.token.here" },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const result = await authenticatePostgresRequest(event);
|
|
38
|
+
|
|
39
|
+
assert.equal(result, null);
|
|
40
|
+
assert.equal(event.requestContext.authorizer?.claims?.sub, "user-abc");
|
|
41
|
+
assert.equal(event.requestContext.authorizer?.claims?.email, "a@b.com");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("invalid token returns 401 and does not inject claims", async () => {
|
|
45
|
+
_setVerifierForTest(async () => {
|
|
46
|
+
throw new Error("bad signature");
|
|
47
|
+
});
|
|
48
|
+
const event = buildEvent({
|
|
49
|
+
headers: { authorization: "Bearer bad.token" },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const result = await authenticatePostgresRequest(event);
|
|
53
|
+
|
|
54
|
+
assert.equal(result?.statusCode, 401);
|
|
55
|
+
assert.equal(event.requestContext.authorizer, undefined);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("no token with a local bypass configured is allowed", async () => {
|
|
59
|
+
process.env.LOCAL_ACCOUNT_CONFIG_ID = "some-config-id";
|
|
60
|
+
const event = buildEvent();
|
|
61
|
+
|
|
62
|
+
const result = await authenticatePostgresRequest(event);
|
|
63
|
+
|
|
64
|
+
assert.equal(result, null);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("no token and no bypass returns 401", async () => {
|
|
68
|
+
const event = buildEvent();
|
|
69
|
+
|
|
70
|
+
const result = await authenticatePostgresRequest(event);
|
|
71
|
+
|
|
72
|
+
assert.equal(result?.statusCode, 401);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("pre-injected claims (edge tier) short-circuit verification", async () => {
|
|
76
|
+
_setVerifierForTest(async () => {
|
|
77
|
+
throw new Error("verifier must not be called");
|
|
78
|
+
});
|
|
79
|
+
const event = buildEvent({
|
|
80
|
+
requestContext: {
|
|
81
|
+
authorizer: { claims: { sub: "edge-user" } },
|
|
82
|
+
} as unknown as APIGatewayProxyEvent["requestContext"],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const result = await authenticatePostgresRequest(event);
|
|
86
|
+
|
|
87
|
+
assert.equal(result, null);
|
|
88
|
+
assert.equal(event.requestContext.authorizer?.claims?.sub, "edge-user");
|
|
89
|
+
});
|
package/src/jwt-auth.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createJwtVerifier,
|
|
3
|
+
extractBearerToken,
|
|
4
|
+
type JwtVerifier,
|
|
5
|
+
resolveVerifierConfig,
|
|
6
|
+
} from "@remit/auth-service/verifier";
|
|
7
|
+
import { logger } from "@remit/logger-lambda";
|
|
8
|
+
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
|
|
9
|
+
|
|
10
|
+
let verifier: JwtVerifier | null = null;
|
|
11
|
+
|
|
12
|
+
const getVerifier = (): JwtVerifier => {
|
|
13
|
+
if (!verifier) verifier = createJwtVerifier(resolveVerifierConfig());
|
|
14
|
+
return verifier;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Test-only override for the memoized verifier. Pass null to reset. */
|
|
18
|
+
export const _setVerifierForTest = (v: JwtVerifier | null): void => {
|
|
19
|
+
verifier = v;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const readHeader = (
|
|
23
|
+
headers: APIGatewayProxyEvent["headers"],
|
|
24
|
+
name: string,
|
|
25
|
+
): string | undefined => {
|
|
26
|
+
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
27
|
+
if (key.toLowerCase() === name && typeof value === "string") return value;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const unauthorized = (message: string): APIGatewayProxyResult => ({
|
|
33
|
+
statusCode: 401,
|
|
34
|
+
headers: { "Content-Type": "application/json" },
|
|
35
|
+
body: JSON.stringify({ message }),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const injectClaims = (
|
|
39
|
+
event: APIGatewayProxyEvent,
|
|
40
|
+
sub: string,
|
|
41
|
+
email: string | undefined,
|
|
42
|
+
): void => {
|
|
43
|
+
event.requestContext = {
|
|
44
|
+
...event.requestContext,
|
|
45
|
+
authorizer: {
|
|
46
|
+
...event.requestContext?.authorizer,
|
|
47
|
+
claims: {
|
|
48
|
+
sub,
|
|
49
|
+
email: email ?? "",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const hasLocalBypass = (): boolean =>
|
|
56
|
+
Boolean(process.env.LOCAL_ACCOUNT_CONFIG_ID);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Authenticate a Postgres-mode request from a better-auth RS256 JWT.
|
|
60
|
+
*
|
|
61
|
+
* On a valid token the verified `sub` is injected into the event's authorizer
|
|
62
|
+
* claims, exactly where the Cognito authorizer puts it, so every downstream
|
|
63
|
+
* handler reads identity through the unchanged `auth.ts` path. Returns a 401
|
|
64
|
+
* response (not null) when a token is present but fails verification, or when no
|
|
65
|
+
* token is supplied and no local test bypass is configured. Returns null to let
|
|
66
|
+
* the request proceed.
|
|
67
|
+
*
|
|
68
|
+
* When the edge tier (APISIX) has already verified and injected claims, this is
|
|
69
|
+
* a no-op — the backend trusts pre-populated claims and re-verifies otherwise.
|
|
70
|
+
*/
|
|
71
|
+
export const authenticatePostgresRequest = async (
|
|
72
|
+
event: APIGatewayProxyEvent,
|
|
73
|
+
): Promise<APIGatewayProxyResult | null> => {
|
|
74
|
+
const existingSub = event.requestContext?.authorizer?.claims?.sub;
|
|
75
|
+
if (typeof existingSub === "string" && existingSub.length > 0) return null;
|
|
76
|
+
|
|
77
|
+
const token = extractBearerToken(readHeader(event.headers, "authorization"));
|
|
78
|
+
|
|
79
|
+
if (token) {
|
|
80
|
+
const claims = await getVerifier()(token).catch((err: unknown) => {
|
|
81
|
+
logger.warn(
|
|
82
|
+
{ err: err instanceof Error ? err.message : String(err) },
|
|
83
|
+
"better-auth JWT verification failed",
|
|
84
|
+
);
|
|
85
|
+
return null;
|
|
86
|
+
});
|
|
87
|
+
if (!claims) return unauthorized("Invalid or expired token");
|
|
88
|
+
injectClaims(event, claims.sub, claims.email);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (hasLocalBypass()) return null;
|
|
93
|
+
|
|
94
|
+
return unauthorized("Authentication required");
|
|
95
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
getRequestOrigin,
|
|
5
|
+
resolveAllowedOrigin,
|
|
6
|
+
runWithRequestContext,
|
|
7
|
+
} from "./request-context.js";
|
|
8
|
+
|
|
9
|
+
describe("request-context", () => {
|
|
10
|
+
const originalEnv = process.env.CORS_ALLOWED_ORIGINS;
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
if (originalEnv === undefined) {
|
|
14
|
+
process.env.CORS_ALLOWED_ORIGINS = undefined;
|
|
15
|
+
process.env.CORS_ALLOWED_ORIGINS = "";
|
|
16
|
+
delete process.env.CORS_ALLOWED_ORIGINS;
|
|
17
|
+
} else {
|
|
18
|
+
process.env.CORS_ALLOWED_ORIGINS = originalEnv;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("stores and retrieves the request origin", () => {
|
|
23
|
+
runWithRequestContext({ origin: "https://example.com" }, () => {
|
|
24
|
+
assert.equal(getRequestOrigin(), "https://example.com");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns undefined when no origin is set", () => {
|
|
29
|
+
runWithRequestContext({}, () => {
|
|
30
|
+
assert.equal(getRequestOrigin(), undefined);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns star when no allow-list is configured", () => {
|
|
35
|
+
delete process.env.CORS_ALLOWED_ORIGINS;
|
|
36
|
+
assert.equal(resolveAllowedOrigin("https://any.com"), "*");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("reflects the request origin when it is allow-listed", () => {
|
|
40
|
+
process.env.CORS_ALLOWED_ORIGINS =
|
|
41
|
+
"https://app.example.com,http://localhost:5173";
|
|
42
|
+
assert.equal(
|
|
43
|
+
resolveAllowedOrigin("http://localhost:5173"),
|
|
44
|
+
"http://localhost:5173",
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("falls back to the first allowed origin when the request origin is not allow-listed", () => {
|
|
49
|
+
process.env.CORS_ALLOWED_ORIGINS =
|
|
50
|
+
"https://app.example.com,http://localhost:5173";
|
|
51
|
+
assert.equal(
|
|
52
|
+
resolveAllowedOrigin("https://evil.example.com"),
|
|
53
|
+
"https://app.example.com",
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("falls back to the first allowed origin when no request origin is provided", () => {
|
|
58
|
+
process.env.CORS_ALLOWED_ORIGINS = "https://app.example.com";
|
|
59
|
+
assert.equal(resolveAllowedOrigin(undefined), "https://app.example.com");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
interface RequestContext {
|
|
4
|
+
origin?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const storage = new AsyncLocalStorage<RequestContext>();
|
|
8
|
+
|
|
9
|
+
export const runWithRequestContext = <T>(ctx: RequestContext, fn: () => T): T =>
|
|
10
|
+
storage.run(ctx, fn);
|
|
11
|
+
|
|
12
|
+
export const getRequestOrigin = (): string | undefined =>
|
|
13
|
+
storage.getStore()?.origin;
|
|
14
|
+
|
|
15
|
+
const parseAllowedOrigins = (): readonly string[] => {
|
|
16
|
+
const raw = process.env.CORS_ALLOWED_ORIGINS;
|
|
17
|
+
if (!raw) return [];
|
|
18
|
+
return raw
|
|
19
|
+
.split(",")
|
|
20
|
+
.map((s) => s.trim())
|
|
21
|
+
.filter((s) => s.length > 0);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const resolveAllowedOrigin = (origin: string | undefined): string => {
|
|
25
|
+
const allowed = parseAllowedOrigins();
|
|
26
|
+
if (allowed.length === 0) return "*";
|
|
27
|
+
if (origin && allowed.includes(origin)) return origin;
|
|
28
|
+
return allowed[0];
|
|
29
|
+
};
|
package/src/request.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
2
|
+
import type { Request } from "openapi-backend";
|
|
3
|
+
|
|
4
|
+
export const normalizeRequest = (event: APIGatewayProxyEvent): Request => ({
|
|
5
|
+
method: event.httpMethod,
|
|
6
|
+
path: event.path,
|
|
7
|
+
query: (event.queryStringParameters ?? {}) as Record<string, string>,
|
|
8
|
+
body: event.body,
|
|
9
|
+
headers: (event.headers ?? {}) as Record<string, string>,
|
|
10
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
+
import type { Context as OpenAPIContext } from "openapi-backend";
|
|
4
|
+
import { postResponseHandler } from "./response.js";
|
|
5
|
+
|
|
6
|
+
type ValidateResponseFn = (
|
|
7
|
+
response: unknown,
|
|
8
|
+
operation: unknown,
|
|
9
|
+
) => { valid: boolean; errors?: { instancePath?: string; message?: string }[] };
|
|
10
|
+
|
|
11
|
+
const makeContext = (
|
|
12
|
+
response: Record<string, unknown>,
|
|
13
|
+
validateResponse: ValidateResponseFn,
|
|
14
|
+
): OpenAPIContext => {
|
|
15
|
+
return {
|
|
16
|
+
api: { validateResponse },
|
|
17
|
+
response,
|
|
18
|
+
operation: { operationId: "testOp" },
|
|
19
|
+
} as unknown as OpenAPIContext;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe("postResponseHandler validation gating", () => {
|
|
23
|
+
const originalStage = process.env.STAGE_NAME;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
delete process.env.STAGE_NAME;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
if (originalStage === undefined) {
|
|
31
|
+
delete process.env.STAGE_NAME;
|
|
32
|
+
} else {
|
|
33
|
+
process.env.STAGE_NAME = originalStage;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("runs validateResponse when STAGE_NAME is 'dev'", () => {
|
|
38
|
+
process.env.STAGE_NAME = "dev";
|
|
39
|
+
let called = false;
|
|
40
|
+
const validate: ValidateResponseFn = () => {
|
|
41
|
+
called = true;
|
|
42
|
+
return { valid: true };
|
|
43
|
+
};
|
|
44
|
+
const result = postResponseHandler(makeContext({ items: [] }, validate));
|
|
45
|
+
assert.equal(called, true);
|
|
46
|
+
assert.equal(result.statusCode, 200);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("skips validateResponse when STAGE_NAME is 'prod'", () => {
|
|
50
|
+
process.env.STAGE_NAME = "prod";
|
|
51
|
+
let called = false;
|
|
52
|
+
const validate: ValidateResponseFn = () => {
|
|
53
|
+
called = true;
|
|
54
|
+
return { valid: true };
|
|
55
|
+
};
|
|
56
|
+
const result = postResponseHandler(makeContext({ items: [] }, validate));
|
|
57
|
+
assert.equal(called, false);
|
|
58
|
+
assert.equal(result.statusCode, 200);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("skips validateResponse when STAGE_NAME is 'beta'", () => {
|
|
62
|
+
process.env.STAGE_NAME = "beta";
|
|
63
|
+
let called = false;
|
|
64
|
+
const validate: ValidateResponseFn = () => {
|
|
65
|
+
called = true;
|
|
66
|
+
return { valid: true };
|
|
67
|
+
};
|
|
68
|
+
postResponseHandler(makeContext({ items: [] }, validate));
|
|
69
|
+
assert.equal(called, false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("skips validateResponse when STAGE_NAME is unset", () => {
|
|
73
|
+
let called = false;
|
|
74
|
+
const validate: ValidateResponseFn = () => {
|
|
75
|
+
called = true;
|
|
76
|
+
return { valid: true };
|
|
77
|
+
};
|
|
78
|
+
postResponseHandler(makeContext({ items: [] }, validate));
|
|
79
|
+
assert.equal(called, false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("returns the formatted error response when statusCode is set, regardless of stage", () => {
|
|
83
|
+
process.env.STAGE_NAME = "dev";
|
|
84
|
+
let called = false;
|
|
85
|
+
const validate: ValidateResponseFn = () => {
|
|
86
|
+
called = true;
|
|
87
|
+
return { valid: true };
|
|
88
|
+
};
|
|
89
|
+
const result = postResponseHandler(
|
|
90
|
+
makeContext({ statusCode: 404, message: "Not Found" }, validate),
|
|
91
|
+
);
|
|
92
|
+
assert.equal(called, false);
|
|
93
|
+
assert.equal(result.statusCode, 404);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("returns 200 with the response body when validation fails in dev", () => {
|
|
97
|
+
process.env.STAGE_NAME = "dev";
|
|
98
|
+
const validate: ValidateResponseFn = () => ({
|
|
99
|
+
valid: false,
|
|
100
|
+
errors: [{ instancePath: "/items/0/id", message: "must be string" }],
|
|
101
|
+
});
|
|
102
|
+
const result = postResponseHandler(
|
|
103
|
+
makeContext({ items: [{ id: 42 }] }, validate),
|
|
104
|
+
);
|
|
105
|
+
assert.equal(result.statusCode, 200);
|
|
106
|
+
});
|
|
107
|
+
});
|
package/src/response.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { logger } from "@remit/logger-lambda";
|
|
2
|
+
import type { APIGatewayProxyResult } from "aws-lambda";
|
|
3
|
+
import type { Context as OpenAPIContext } from "openapi-backend";
|
|
4
|
+
import { getRequestOrigin, resolveAllowedOrigin } from "./request-context.js";
|
|
5
|
+
|
|
6
|
+
export const formatResponse = (
|
|
7
|
+
body: Record<string, unknown>,
|
|
8
|
+
statusCode = 200,
|
|
9
|
+
): APIGatewayProxyResult => {
|
|
10
|
+
if (body.statusCode && typeof body.statusCode === "number") {
|
|
11
|
+
statusCode = body.statusCode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if ("body" in body && body.body && typeof body.body === "object") {
|
|
15
|
+
body = body.body as Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
logger.debug({ statusCode }, "response");
|
|
19
|
+
|
|
20
|
+
const allowOrigin = resolveAllowedOrigin(getRequestOrigin());
|
|
21
|
+
|
|
22
|
+
const corsHeaders: Record<string, string> = {
|
|
23
|
+
"Access-Control-Allow-Origin": allowOrigin,
|
|
24
|
+
"Access-Control-Allow-Headers": "Authorization,Content-Type",
|
|
25
|
+
"Access-Control-Allow-Methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
|
|
26
|
+
Vary: "Origin",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
if (allowOrigin !== "*") {
|
|
30
|
+
corsHeaders["Access-Control-Allow-Credentials"] = "true";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
statusCode: statusCode,
|
|
35
|
+
headers: {
|
|
36
|
+
"Content-Type": "application/json",
|
|
37
|
+
...corsHeaders,
|
|
38
|
+
},
|
|
39
|
+
body: JSON.stringify(body),
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const postResponseHandler = (context: OpenAPIContext) => {
|
|
44
|
+
const { api, response, operation } = context;
|
|
45
|
+
|
|
46
|
+
if (response.statusCode) return formatResponse(response, response.statusCode);
|
|
47
|
+
|
|
48
|
+
if (process.env.STAGE_NAME !== "dev") return formatResponse(response, 200);
|
|
49
|
+
|
|
50
|
+
const { valid, errors } = api.validateResponse(response, operation);
|
|
51
|
+
|
|
52
|
+
if (valid) return formatResponse(response, 200);
|
|
53
|
+
|
|
54
|
+
const { operationId } = operation;
|
|
55
|
+
|
|
56
|
+
const errorDetails: Record<string, unknown> = {
|
|
57
|
+
errors,
|
|
58
|
+
operationId,
|
|
59
|
+
responseKeys: Object.keys(response),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
errors?.forEach((error) => {
|
|
63
|
+
if (error.instancePath) {
|
|
64
|
+
const pathParts = error.instancePath.split("/").filter(Boolean);
|
|
65
|
+
let value = response;
|
|
66
|
+
for (const part of pathParts) {
|
|
67
|
+
value = value?.[part];
|
|
68
|
+
}
|
|
69
|
+
errorDetails[`problematicValue_${error.instancePath}`] = {
|
|
70
|
+
path: error.instancePath,
|
|
71
|
+
value: JSON.stringify(value).substring(0, 500),
|
|
72
|
+
error: error.message,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
logger.error(errorDetails, "Response validation failed");
|
|
78
|
+
|
|
79
|
+
return formatResponse(response, 200);
|
|
80
|
+
};
|