@remit/backend 0.0.144 → 0.0.146

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 (91) hide show
  1. package/dist/src/api.d.ts +0 -2
  2. package/dist/src/api.js +9 -80
  3. package/dist/src/auth.d.ts +13 -19
  4. package/dist/src/auth.js +51 -43
  5. package/dist/src/config/api-config.d.ts +20 -0
  6. package/dist/src/config/api-config.js +1 -0
  7. package/dist/src/config/from-env.d.ts +2 -0
  8. package/dist/src/config/from-env.js +65 -0
  9. package/dist/src/config/msoauth-secrets-manager.d.ts +2 -0
  10. package/dist/src/config/msoauth-secrets-manager.js +28 -0
  11. package/dist/src/config/msoauth.d.ts +18 -29
  12. package/dist/src/config/msoauth.js +4 -28
  13. package/dist/src/derive/contentSignature.d.ts +3 -4
  14. package/dist/src/derive/contentSignature.js +4 -13
  15. package/dist/src/derive/contentUrl.d.ts +0 -13
  16. package/dist/src/derive/contentUrl.js +0 -19
  17. package/dist/src/handlers/account-oauth.d.ts +9 -3
  18. package/dist/src/handlers/account-oauth.js +197 -250
  19. package/dist/src/handlers/account.d.ts +13 -4
  20. package/dist/src/handlers/account.js +26 -39
  21. package/dist/src/handlers/address.d.ts +11 -3
  22. package/dist/src/handlers/address.js +11 -19
  23. package/dist/src/handlers/calendar-event.js +18 -26
  24. package/dist/src/handlers/calendar-feed.d.ts +2 -2
  25. package/dist/src/handlers/calendar-feed.js +24 -32
  26. package/dist/src/handlers/calendar-suggestion.js +18 -26
  27. package/dist/src/handlers/calendar.d.ts +1 -1
  28. package/dist/src/handlers/calendar.js +15 -22
  29. package/dist/src/handlers/config.d.ts +16 -6
  30. package/dist/src/handlers/config.js +23 -35
  31. package/dist/src/handlers/filter.d.ts +16 -3
  32. package/dist/src/handlers/filter.js +17 -32
  33. package/dist/src/handlers/folder-role.js +3 -6
  34. package/dist/src/handlers/index.d.ts +187 -2
  35. package/dist/src/handlers/index.js +6 -4
  36. package/dist/src/handlers/label.js +15 -22
  37. package/dist/src/handlers/mailbox.d.ts +20 -4
  38. package/dist/src/handlers/mailbox.js +18 -26
  39. package/dist/src/handlers/me.d.ts +10 -2
  40. package/dist/src/handlers/me.js +26 -47
  41. package/dist/src/handlers/message.d.ts +78 -6
  42. package/dist/src/handlers/message.js +34 -46
  43. package/dist/src/handlers/organize.d.ts +10 -4
  44. package/dist/src/handlers/organize.js +11 -20
  45. package/dist/src/handlers/outbox-attachment.d.ts +6 -5
  46. package/dist/src/handlers/outbox-attachment.js +11 -15
  47. package/dist/src/handlers/outbox.d.ts +25 -4
  48. package/dist/src/handlers/outbox.js +27 -35
  49. package/dist/src/handlers/sync.d.ts +19 -6
  50. package/dist/src/handlers/sync.js +13 -22
  51. package/dist/src/handlers/system-update.js +14 -27
  52. package/dist/src/handlers/thread.js +9 -14
  53. package/dist/src/handlers/unified-threads.js +3 -6
  54. package/dist/src/index.d.ts +4 -1
  55. package/dist/src/index.js +18 -2
  56. package/dist/src/jwt-auth.d.ts +9 -12
  57. package/dist/src/jwt-auth.js +19 -71
  58. package/dist/src/lambda.d.ts +13 -0
  59. package/dist/src/lambda.js +33 -0
  60. package/dist/src/request.d.ts +8 -2
  61. package/dist/src/request.js +16 -6
  62. package/dist/src/response.d.ts +20 -3
  63. package/dist/src/response.js +15 -22
  64. package/dist/src/serve.d.ts +31 -0
  65. package/dist/src/serve.js +117 -0
  66. package/dist/src/service/account-deletion.d.ts +2 -4
  67. package/dist/src/service/account-deletion.js +3 -9
  68. package/dist/src/service/compose-sqlite.d.ts +2 -1
  69. package/dist/src/service/compose-sqlite.js +5 -3
  70. package/dist/src/service/create-remit-client.d.ts +25 -11
  71. package/dist/src/service/create-remit-client.js +97 -116
  72. package/dist/src/service/data-client.js +3 -5
  73. package/dist/src/service/export-identity.d.ts +2 -1
  74. package/dist/src/service/export-identity.js +4 -6
  75. package/dist/src/service/fire-and-forget.js +1 -2
  76. package/dist/src/service/organize.d.ts +7 -10
  77. package/dist/src/service/organize.js +10 -27
  78. package/dist/src/service/queues.d.ts +12 -0
  79. package/dist/src/service/queues.js +1 -0
  80. package/dist/src/service/shared-deps.d.ts +6 -0
  81. package/dist/src/service/shared-deps.js +26 -0
  82. package/dist/src/service/trigger-sync.d.ts +11 -5
  83. package/dist/src/service/trigger-sync.js +10 -18
  84. package/dist/src/types.d.ts +24 -1
  85. package/package.json +24 -8
  86. package/dist/src/data-backend.d.ts +0 -12
  87. package/dist/src/data-backend.js +0 -12
  88. package/dist/src/request-context.d.ts +0 -9
  89. package/dist/src/request-context.js +0 -22
  90. package/dist/src/service/sqs.d.ts +0 -1
  91. package/dist/src/service/sqs.js +0 -3
package/dist/src/index.js CHANGED
@@ -1,9 +1,25 @@
1
1
  import { inspect } from "node:util";
2
2
  import { logger } from "@remit/logger";
3
3
  import { installNodeLogger, withTelemetry } from "@remit/logger-lambda/node";
4
- import { handleApiEvent } from "./api.js";
4
+ import { assertLocalBypassNotInDeployedEnv, localIdentityFromEnv, } from "./auth.js";
5
+ import { configFromEnv } from "./config/from-env.js";
6
+ import { handlers, publicHandlers } from "./handlers/index.js";
7
+ import { createApiEventHandler } from "./lambda.js";
8
+ import { serveApi } from "./serve.js";
9
+ import { getClient } from "./service/data-client.js";
5
10
  installNodeLogger();
6
- export { api, handleApiEvent, OpenAPISpec } from "./api.js";
11
+ // Boot-time refusal: the local auth-bypass overrides must never be set in a
12
+ // deployed environment, so a leaked env var fails the process at startup
13
+ // rather than silently opening authentication at runtime.
14
+ assertLocalBypassNotInDeployedEnv(process.env);
15
+ export { OpenAPISpec } from "./api.js";
16
+ export { configFromEnv } from "./config/from-env.js";
17
+ export { createApiEventHandler } from "./lambda.js";
18
+ export const handleApiEvent = createApiEventHandler(serveApi({ handlers, publicHandlers }), {
19
+ config: configFromEnv(process.env),
20
+ localIdentity: localIdentityFromEnv(process.env),
21
+ client: getClient,
22
+ });
7
23
  /**
8
24
  * Last-resort net for a leaked background rejection.
9
25
  *
@@ -1,18 +1,15 @@
1
1
  import { type JwtVerifier } from "@remit/auth-service/verifier";
2
- import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
2
+ import type { APIGatewayProxyEvent } from "aws-lambda";
3
+ import type { HandlerIdentity } from "./types.js";
3
4
  /** Test-only override for the memoized verifier. Pass null to reset. */
4
5
  export declare const _setVerifierForTest: (v: JwtVerifier | null) => void;
5
6
  /**
6
- * Authenticate a self-host request from a better-auth RS256 JWT.
7
+ * The caller of a self-host request, from a better-auth RS256 JWT.
7
8
  *
8
- * On a valid token the verified `sub` is injected into the event's authorizer
9
- * claims, exactly where the Cognito authorizer puts it, so every downstream
10
- * handler reads identity through the unchanged `auth.ts` path. Returns a 401
11
- * response (not null) when a token is present but fails verification, or when no
12
- * token is supplied and no local test bypass is configured. Returns null to let
13
- * the request proceed.
14
- *
15
- * When the edge tier (APISIX) has already verified and injected claims, this is
16
- * a no-op — the backend trusts pre-populated claims and re-verifies otherwise.
9
+ * Claims the edge tier (APISIX) already verified and injected are trusted as
10
+ * they stand. Otherwise a bearer token is verified here; a token that fails
11
+ * verification is an anonymous caller, so the operation table answers 401. With
12
+ * no token the local bypass, when one is configured, is the caller. Only an
13
+ * operation that needs a caller resolves this, so a public route never does.
17
14
  */
18
- export declare const authenticateSelfHostRequest: (event: APIGatewayProxyEvent) => Promise<APIGatewayProxyResult | null>;
15
+ export declare const identityFromSelfHostRequest: (event: APIGatewayProxyEvent, localIdentity: HandlerIdentity) => Promise<HandlerIdentity>;
@@ -1,8 +1,6 @@
1
1
  import { createJwtVerifier, extractBearerToken, resolveVerifierConfig, } from "@remit/auth-service/verifier";
2
- import { readCalendarFeedToken } from "@remit/calendar-service";
3
2
  import { logger } from "@remit/logger";
4
- import { defaultErrorCode } from "./error.js";
5
- import { formatResponse } from "./response.js";
3
+ import { getIdentityFromEvent, userFromClaims } from "./auth.js";
6
4
  let verifier = null;
7
5
  const getVerifier = () => {
8
6
  if (!verifier)
@@ -20,78 +18,28 @@ const readHeader = (headers, name) => {
20
18
  }
21
19
  return undefined;
22
20
  };
23
- const unauthorized = (message) => formatResponse({ code: defaultErrorCode(401), message }, 401);
24
- const injectClaims = (event, sub, email) => {
25
- event.requestContext = {
26
- ...event.requestContext,
27
- authorizer: {
28
- ...event.requestContext?.authorizer,
29
- claims: {
30
- sub,
31
- email: email ?? "",
32
- },
33
- },
34
- };
35
- };
36
- const hasLocalBypass = () => Boolean(process.env.LOCAL_ACCOUNT_CONFIG_ID);
37
- /**
38
- * The routes that run without a token.
39
- *
40
- * Microsoft redirects the browser here after consent, so the request carries no
41
- * Authorization header and no session to derive one from. The handler takes its
42
- * identity from the HMAC-signed `state` parameter and validates it there; that
43
- * signature is the gate, not a JWT.
44
- *
45
- * A calendar feed carries its credential in the path for the same reason: a
46
- * subscribed client polls a URL and has no session to present, so the token is
47
- * the gate and the handler compares it against a stored hash.
48
- *
49
- * Hardcoded, matching the edge exemption in packages/apisix/src/route-table.ts.
50
- * `@useAuth(NoAuth)` in the spec does not open a path here — a future public
51
- * route is added by hand.
52
- */
53
- const PUBLIC_ROUTES = [
54
- {
55
- method: "GET",
56
- matches: (path) => path === "/accounts/oauth/microsoft/callback",
57
- },
58
- {
59
- method: "GET",
60
- matches: (path) => readCalendarFeedToken(path) !== null,
61
- },
62
- ];
63
- const isPublicRoute = (event) => PUBLIC_ROUTES.some((route) => route.method === event.httpMethod && route.matches(event.path));
64
21
  /**
65
- * Authenticate a self-host request from a better-auth RS256 JWT.
22
+ * The caller of a self-host request, from a better-auth RS256 JWT.
66
23
  *
67
- * On a valid token the verified `sub` is injected into the event's authorizer
68
- * claims, exactly where the Cognito authorizer puts it, so every downstream
69
- * handler reads identity through the unchanged `auth.ts` path. Returns a 401
70
- * response (not null) when a token is present but fails verification, or when no
71
- * token is supplied and no local test bypass is configured. Returns null to let
72
- * the request proceed.
73
- *
74
- * When the edge tier (APISIX) has already verified and injected claims, this is
75
- * a no-op — the backend trusts pre-populated claims and re-verifies otherwise.
24
+ * Claims the edge tier (APISIX) already verified and injected are trusted as
25
+ * they stand. Otherwise a bearer token is verified here; a token that fails
26
+ * verification is an anonymous caller, so the operation table answers 401. With
27
+ * no token the local bypass, when one is configured, is the caller. Only an
28
+ * operation that needs a caller resolves this, so a public route never does.
76
29
  */
77
- export const authenticateSelfHostRequest = async (event) => {
78
- if (isPublicRoute(event))
79
- return null;
30
+ export const identityFromSelfHostRequest = async (event, localIdentity) => {
80
31
  const existingSub = event.requestContext?.authorizer?.claims?.sub;
81
- if (typeof existingSub === "string" && existingSub.length > 0)
82
- return null;
83
- const token = extractBearerToken(readHeader(event.headers, "authorization"));
84
- if (token) {
85
- const claims = await getVerifier()(token).catch((err) => {
86
- logger.warn({ err: err instanceof Error ? err.message : String(err) }, "better-auth JWT verification failed");
87
- return null;
88
- });
89
- if (!claims)
90
- return unauthorized("Invalid or expired token");
91
- injectClaims(event, claims.sub, claims.email);
92
- return null;
32
+ if (typeof existingSub === "string" && existingSub.length > 0) {
33
+ return getIdentityFromEvent(event, localIdentity);
93
34
  }
94
- if (hasLocalBypass())
35
+ const token = extractBearerToken(readHeader(event.headers, "authorization"));
36
+ if (!token)
37
+ return localIdentity;
38
+ const claims = await getVerifier()(token).catch((err) => {
39
+ logger.warn({ err: err instanceof Error ? err.message : String(err) }, "better-auth JWT verification failed");
95
40
  return null;
96
- return unauthorized("Authentication required");
41
+ });
42
+ if (!claims)
43
+ return { kind: "anonymous" };
44
+ return userFromClaims(claims.sub, claims.email);
97
45
  };
@@ -0,0 +1,13 @@
1
+ import type { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from "aws-lambda";
2
+ import type { ApiConfig } from "./config/api-config.js";
3
+ import type { ServedApi } from "./serve.js";
4
+ import type { RemitClient } from "./service/data-client.js";
5
+ import type { QueueName } from "./service/queues.js";
6
+ import type { HandlerIdentity } from "./types.js";
7
+ export interface ApiEventHandlerOptions<K extends QueueName> {
8
+ config: ApiConfig;
9
+ localIdentity: HandlerIdentity;
10
+ client: () => Promise<RemitClient<K>>;
11
+ }
12
+ export type ApiEventHandler = (event: APIGatewayProxyEvent, context: Context) => Promise<APIGatewayProxyResult>;
13
+ export declare const createApiEventHandler: <K extends QueueName>(served: ServedApi<K>, { config, localIdentity, client }: ApiEventHandlerOptions<NoInfer<K>>) => ApiEventHandler;
@@ -0,0 +1,33 @@
1
+ import { getIdentityFromEvent } from "./auth.js";
2
+ import { identityFromSelfHostRequest } from "./jwt-auth.js";
3
+ import { normalizeRequest } from "./request.js";
4
+ const queryOf = (event) => {
5
+ if (event.multiValueQueryStringParameters) {
6
+ return event.multiValueQueryStringParameters;
7
+ }
8
+ return Object.fromEntries(Object.entries(event.queryStringParameters ?? {}).map(([key, value]) => [
9
+ key,
10
+ value === undefined ? undefined : [value],
11
+ ]));
12
+ };
13
+ const headersOf = (event) => ({
14
+ ...event.headers,
15
+ ...Object.fromEntries(Object.entries(event.multiValueHeaders ?? {}).map(([name, values]) => [
16
+ name,
17
+ values?.join(", "),
18
+ ])),
19
+ });
20
+ const normalizeApiGatewayEvent = (event) => normalizeRequest({
21
+ method: event.httpMethod,
22
+ path: event.path,
23
+ query: queryOf(event),
24
+ headers: headersOf(event),
25
+ body: event.body,
26
+ });
27
+ export const createApiEventHandler = (served, { config, localIdentity, client }) => (event, context) => served.dispatch(normalizeApiGatewayEvent(event), {
28
+ config,
29
+ client,
30
+ identity: async () => config.auth.mode === "BetterAuthJwt"
31
+ ? identityFromSelfHostRequest(event, localIdentity)
32
+ : getIdentityFromEvent(event, localIdentity),
33
+ }, context.awsRequestId);
@@ -1,3 +1,9 @@
1
- import type { APIGatewayProxyEvent } from "aws-lambda";
2
1
  import type { Request } from "openapi-backend";
3
- export declare const normalizeRequest: (event: APIGatewayProxyEvent) => Request;
2
+ export interface HostRequest {
3
+ method: string;
4
+ path: string;
5
+ query: Readonly<Record<string, readonly string[] | undefined>> | null;
6
+ headers: Readonly<Record<string, string | undefined>> | null;
7
+ body: string | null;
8
+ }
9
+ export declare const normalizeRequest: (request: HostRequest) => Request;
@@ -1,7 +1,17 @@
1
- export const normalizeRequest = (event) => ({
2
- method: event.httpMethod,
3
- path: event.path,
4
- query: (event.queryStringParameters ?? {}),
5
- body: event.body,
6
- headers: (event.headers ?? {}),
1
+ const definedHeaders = (map) => Object.fromEntries(Object.entries(map ?? {}).filter((entry) => entry[1] !== undefined));
2
+ const collapsedQuery = (map) => {
3
+ const query = {};
4
+ for (const [key, values] of Object.entries(map ?? {})) {
5
+ if (!values || values.length === 0)
6
+ continue;
7
+ query[key] = values.length === 1 ? values[0] : [...values];
8
+ }
9
+ return query;
10
+ };
11
+ export const normalizeRequest = (request) => ({
12
+ method: request.method,
13
+ path: request.path,
14
+ query: collapsedQuery(request.query),
15
+ body: request.body === "" ? null : request.body,
16
+ headers: definedHeaders(request.headers),
7
17
  });
@@ -1,5 +1,6 @@
1
1
  import type { APIGatewayProxyResult } from "aws-lambda";
2
2
  import type { Context as OpenAPIContext } from "openapi-backend";
3
+ import type { ApiConfig } from "./config/api-config.js";
3
4
  declare const RAW_API_RESPONSE: unique symbol;
4
5
  /**
5
6
  * A response the caller reads by something other than its JSON body — an
@@ -10,8 +11,8 @@ declare const RAW_API_RESPONSE: unique symbol;
10
11
  * stays the default; a handler that has to own its own media type or headers
11
12
  * says so with `rawApiResponse` rather than by returning a shape this one has
12
13
  * to guess at.
13
- * The CORS and correlation headers are still added here, so a raw response is
14
- * not a way around them.
14
+ * `dispatch` still adds the envelope headers, so a raw response is not a way
15
+ * around them.
15
16
  */
16
17
  export interface RawApiResponse {
17
18
  statusCode: number;
@@ -22,6 +23,22 @@ type MarkedRawApiResponse = RawApiResponse & {
22
23
  readonly [RAW_API_RESPONSE]: true;
23
24
  };
24
25
  export declare const rawApiResponse: (response: RawApiResponse) => MarkedRawApiResponse;
26
+ /**
27
+ * The headers every response carries whatever its body is: the CORS grant the
28
+ * browser needs, and the id the request's log lines are already tagged with.
29
+ * Returning that id is what makes a bug report's "correlation id" resolve to a
30
+ * server-side line; the browser can only read a non-safelisted header when it
31
+ * is exposed.
32
+ */
33
+ export interface EnvelopeInput {
34
+ origin: string | undefined;
35
+ allowedOrigins: readonly string[];
36
+ correlationId: string;
37
+ }
38
+ export declare const resolveAllowedOrigin: (origin: string | undefined, allowed: readonly string[]) => string;
39
+ export declare const envelopeHeaders: ({ origin, allowedOrigins, correlationId, }: EnvelopeInput) => Record<string, string>;
25
40
  export declare const formatResponse: (body: Record<string, unknown>, statusCode?: number) => APIGatewayProxyResult;
26
- export declare const postResponseHandler: (context: OpenAPIContext) => APIGatewayProxyResult;
41
+ export declare const postResponseHandler: (context: OpenAPIContext, scope: {
42
+ config: ApiConfig;
43
+ }) => APIGatewayProxyResult;
27
44
  export {};
@@ -1,17 +1,16 @@
1
1
  import { logger } from "@remit/logger";
2
- import { getRequestCorrelationId, getRequestOrigin, resolveAllowedOrigin, } from "./request-context.js";
3
2
  const RAW_API_RESPONSE = Symbol("remit.rawApiResponse");
4
3
  export const rawApiResponse = (response) => Object.assign(response, { [RAW_API_RESPONSE]: true });
5
4
  const isRawApiResponse = (body) => RAW_API_RESPONSE in body;
6
- /**
7
- * The headers every response carries whatever its body is: the CORS grant the
8
- * browser needs, and the id the request's log lines are already tagged with.
9
- * Returning that id is what makes a bug report's "correlation id" resolve to a
10
- * server-side line; the browser can only read a non-safelisted header when it
11
- * is exposed.
12
- */
13
- const envelopeHeaders = () => {
14
- const allowOrigin = resolveAllowedOrigin(getRequestOrigin());
5
+ export const resolveAllowedOrigin = (origin, allowed) => {
6
+ if (allowed.length === 0)
7
+ return "*";
8
+ if (origin && allowed.includes(origin))
9
+ return origin;
10
+ return allowed[0];
11
+ };
12
+ export const envelopeHeaders = ({ origin, allowedOrigins, correlationId, }) => {
13
+ const allowOrigin = resolveAllowedOrigin(origin, allowedOrigins);
15
14
  const headers = {
16
15
  "Access-Control-Allow-Origin": allowOrigin,
17
16
  "Access-Control-Allow-Headers": "Authorization,Content-Type",
@@ -21,11 +20,8 @@ const envelopeHeaders = () => {
21
20
  if (allowOrigin !== "*") {
22
21
  headers["Access-Control-Allow-Credentials"] = "true";
23
22
  }
24
- const correlationId = getRequestCorrelationId();
25
- if (correlationId) {
26
- headers["x-correlation-id"] = correlationId;
27
- headers["Access-Control-Expose-Headers"] = "x-correlation-id";
28
- }
23
+ headers["x-correlation-id"] = correlationId;
24
+ headers["Access-Control-Expose-Headers"] = "x-correlation-id";
29
25
  return headers;
30
26
  };
31
27
  export const formatResponse = (body, statusCode = 200) => {
@@ -36,7 +32,7 @@ export const formatResponse = (body, statusCode = 200) => {
36
32
  logger.debug({ statusCode: body.statusCode }, "response");
37
33
  return {
38
34
  statusCode: body.statusCode,
39
- headers: { ...envelopeHeaders(), ...body.headers },
35
+ headers: body.headers,
40
36
  body: body.body,
41
37
  };
42
38
  }
@@ -46,18 +42,15 @@ export const formatResponse = (body, statusCode = 200) => {
46
42
  logger.debug({ statusCode }, "response");
47
43
  return {
48
44
  statusCode: statusCode,
49
- headers: {
50
- "Content-Type": "application/json",
51
- ...envelopeHeaders(),
52
- },
45
+ headers: { "Content-Type": "application/json" },
53
46
  body: JSON.stringify(body),
54
47
  };
55
48
  };
56
- export const postResponseHandler = (context) => {
49
+ export const postResponseHandler = (context, scope) => {
57
50
  const { api, response, operation } = context;
58
51
  if (response.statusCode)
59
52
  return formatResponse(response, response.statusCode);
60
- if (process.env.STAGE_NAME !== "dev")
53
+ if (scope.config.responseValidation === "Skip")
61
54
  return formatResponse(response, 200);
62
55
  const { valid, errors } = api.validateResponse(response, operation);
63
56
  if (valid)
@@ -0,0 +1,31 @@
1
+ import type { APIGatewayProxyResult } from "aws-lambda";
2
+ import type { Request as OpenAPIRequest } from "openapi-backend";
3
+ import type { ApiConfig } from "./config/api-config.js";
4
+ import type { RemitClient } from "./service/data-client.js";
5
+ import type { QueueName } from "./service/queues.js";
6
+ import type { HandlerIdentity, OperationHandler, OperationIds, PublicOperationHandler, PublicOperationIds } from "./types.js";
7
+ export type { ApiConfig } from "./config/api-config.js";
8
+ export type { HandlerContext, HandlerIdentity, PublicHandlerContext, UserIdentity, } from "./types.js";
9
+ export interface RequestHost<K extends QueueName> {
10
+ config: ApiConfig;
11
+ client: () => Promise<RemitClient<K>>;
12
+ identity: () => Promise<HandlerIdentity>;
13
+ }
14
+ /**
15
+ * The operations a host serves, each typed by the queues it sends to. An
16
+ * operation the host leaves out answers 501.
17
+ */
18
+ export interface ApiRegistry<K extends QueueName> {
19
+ handlers: Partial<Record<Exclude<OperationIds, PublicOperationIds>, OperationHandler<OperationIds, K>>>;
20
+ publicHandlers: Partial<Record<PublicOperationIds, PublicOperationHandler<K>>>;
21
+ }
22
+ export interface ServedApi<K extends QueueName> {
23
+ dispatch: (request: OpenAPIRequest, host: RequestHost<K>, correlationId: string) => Promise<APIGatewayProxyResult>;
24
+ handleFetch: (request: Request, host: RequestHost<K>, correlationId: string) => Promise<Response>;
25
+ }
26
+ /**
27
+ * Register a host's operations and return the two ways in: a normalised
28
+ * request, or a Fetch `Request`. Each request's host supplies the client, and
29
+ * the client's queues must cover every operation in the registry.
30
+ */
31
+ export declare const serveApi: <K extends QueueName>(registry: ApiRegistry<NoInfer<K>>) => ServedApi<K>;
@@ -0,0 +1,117 @@
1
+ import { redactCalendarFeedPath } from "@remit/calendar-service";
2
+ import { logger, withLogContext } from "@remit/logger";
3
+ import { api } from "./api.js";
4
+ import { defaultErrorCode, handleError } from "./error.js";
5
+ import { normalizeRequest } from "./request.js";
6
+ import { envelopeHeaders } from "./response.js";
7
+ const unauthenticated = () => ({
8
+ statusCode: 401,
9
+ body: { code: defaultErrorCode(401), message: "Unauthorized" },
10
+ });
11
+ const isDefined = (value) => value !== undefined;
12
+ const entriesOf = (record) => Object.entries(record).flatMap(([key, value]) => isDefined(value) ? [[key, value]] : []);
13
+ /**
14
+ * Register a host's operations and return the two ways in: a normalised
15
+ * request, or a Fetch `Request`. Each request's host supplies the client, and
16
+ * the client's queues must cover every operation in the registry.
17
+ */
18
+ export const serveApi = (registry) => {
19
+ api.register({
20
+ ...Object.fromEntries(entriesOf(registry.handlers).map(([operationId, handler]) => [
21
+ operationId,
22
+ async (c, host) => {
23
+ const identity = await host.identity();
24
+ if (identity.kind === "anonymous")
25
+ return unauthenticated();
26
+ return handler(c, {
27
+ client: await host.client(),
28
+ identity,
29
+ config: host.config,
30
+ });
31
+ },
32
+ ])),
33
+ ...Object.fromEntries(entriesOf(registry.publicHandlers).map(([operationId, handler]) => [
34
+ operationId,
35
+ async (c, host) => handler(c, { client: await host.client(), config: host.config }),
36
+ ])),
37
+ });
38
+ return { dispatch, handleFetch };
39
+ };
40
+ const readHeader = (headers, name) => {
41
+ for (const [key, value] of Object.entries(headers ?? {})) {
42
+ if (key.toLowerCase() === name && typeof value === "string")
43
+ return value;
44
+ }
45
+ return undefined;
46
+ };
47
+ const dispatch = async (request, host, correlationId) => {
48
+ // A calendar feed carries its credential in the path, so the raw path is a
49
+ // secret and cannot be a log field (issue #1065). Redacted once, here, where
50
+ // every line inside the request picks it up from the scope.
51
+ const path = redactCalendarFeedPath(request.path);
52
+ const { method } = request;
53
+ return withLogContext({ path, method }, async () => {
54
+ logger.debug({ method, path }, "Request received");
55
+ const startedAt = Date.now();
56
+ const answered = await api
57
+ .handleRequest(request, host)
58
+ .catch(handleError);
59
+ const result = {
60
+ ...answered,
61
+ headers: {
62
+ ...envelopeHeaders({
63
+ origin: readHeader(request.headers, "origin"),
64
+ allowedOrigins: host.config.corsAllowedOrigins,
65
+ correlationId,
66
+ }),
67
+ ...answered.headers,
68
+ },
69
+ };
70
+ // One structured access line per request: method, path, status, and
71
+ // duration. The correlation id is inlined as a top-level field so a bug
72
+ // report quoting the id can grep logs by it directly, and emitted at
73
+ // info so it is on by default in production (the debug "Request
74
+ // received" above already filters out under normal log levels).
75
+ // biome-ignore lint/plugin/no-logger-info: one access line per request is an audit-grade signal (issue #1054)
76
+ logger.info({
77
+ method,
78
+ path,
79
+ statusCode: result.statusCode,
80
+ durationMs: Date.now() - startedAt,
81
+ correlationId,
82
+ }, "Request completed");
83
+ return result;
84
+ });
85
+ };
86
+ const queryOf = (params) => {
87
+ const query = {};
88
+ for (const [key, value] of params) {
89
+ query[key] = [...(query[key] ?? []), value];
90
+ }
91
+ return query;
92
+ };
93
+ const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
94
+ const toFetchResponse = (result) => {
95
+ const headers = new Headers();
96
+ for (const [name, value] of Object.entries(result.headers ?? {})) {
97
+ headers.set(name, String(value));
98
+ }
99
+ for (const [name, values] of Object.entries(result.multiValueHeaders ?? {})) {
100
+ for (const value of values)
101
+ headers.append(name, String(value));
102
+ }
103
+ const body = NULL_BODY_STATUSES.has(result.statusCode) ? null : result.body;
104
+ return new Response(body, { status: result.statusCode, headers });
105
+ };
106
+ const handleFetch = async (request, host, correlationId) => {
107
+ const url = new URL(request.url);
108
+ const body = request.body === null ? null : await request.text();
109
+ const result = await dispatch(normalizeRequest({
110
+ method: request.method,
111
+ path: url.pathname,
112
+ query: queryOf(url.searchParams),
113
+ headers: Object.fromEntries(request.headers),
114
+ body,
115
+ }), host, correlationId);
116
+ return toFetchResponse(result);
117
+ };
@@ -1,10 +1,8 @@
1
- import { type SQSClient } from "@aws-sdk/client-sqs";
2
- import type { AccountItem, IAccountRepository, IConfigImportRepository } from "@remit/data-ports";
1
+ import type { AccountDataPurgeEvent, AccountItem, IAccountRepository, IConfigImportRepository, QueueProducer } from "@remit/data-ports";
3
2
  import type { Logger } from "@remit/logger";
4
3
  export interface AccountDeletionDeps {
5
4
  account: Pick<IAccountRepository, "update">;
6
- sqsClient: SQSClient;
7
- accountFanoutQueueUrl: string;
5
+ queue: QueueProducer<AccountDataPurgeEvent>;
8
6
  log: Logger;
9
7
  }
10
8
  export declare const deleteAccount: (deps: AccountDeletionDeps, account: Pick<AccountItem, "accountId" | "accountConfigId">, deletedAt: number) => Promise<void>;
@@ -1,16 +1,10 @@
1
- import { SendMessageCommand } from "@aws-sdk/client-sqs";
2
1
  import { readConfigDocument } from "@remit/config-format";
3
2
  export const deleteAccount = async (deps, account, deletedAt) => {
4
3
  const { accountId, accountConfigId } = account;
5
4
  // Purge first: an account whose request never left still reads as not deleted, so it is found and asked for again.
6
- await deps.sqsClient.send(new SendMessageCommand({
7
- QueueUrl: deps.accountFanoutQueueUrl,
8
- MessageBody: JSON.stringify({
9
- type: "AccountDataPurge",
10
- accountId,
11
- accountConfigId,
12
- }),
13
- }));
5
+ await deps.queue.send({
6
+ body: { type: "AccountDataPurge", accountId, accountConfigId },
7
+ });
14
8
  await deps.account.update(accountId, { deletedAt, isActive: false });
15
9
  deps.log.info({ accountId, accountConfigId }, "Account data purge initiated");
16
10
  };
@@ -1,2 +1,3 @@
1
- import { type RemitClient } from "./create-remit-client.js";
1
+ import { type RemitClient, type RemitClientSharedDeps } from "./create-remit-client.js";
2
+ export declare const composeSqliteClient: (shared: RemitClientSharedDeps) => Promise<RemitClient>;
2
3
  export declare const buildSqliteClient: () => Promise<RemitClient>;
@@ -1,11 +1,13 @@
1
1
  import { createSqliteRepositories, openSqliteDatabases, sqliteDatabasePathsFromEnv, } from "@remit/drizzle-service";
2
- import { buildSharedDeps, createRemitClient, } from "./create-remit-client.js";
2
+ import { createRemitClient, } from "./create-remit-client.js";
3
+ import { buildSharedDeps, sqsQueues, sqsQueueUrlsFromEnv, } from "./shared-deps.js";
3
4
  // The SQLite adapter composition (RFC 036). Every repo runs on the database
4
5
  // that holds its tables: app, tenant mail or tenant search (#1420).
5
- export const buildSqliteClient = async () => {
6
+ export const composeSqliteClient = async (shared) => {
6
7
  const databases = await openSqliteDatabases(sqliteDatabasePathsFromEnv());
7
8
  return createRemitClient({
8
9
  repositories: createSqliteRepositories(databases),
9
- ...buildSharedDeps(),
10
+ ...shared,
10
11
  });
11
12
  };
13
+ export const buildSqliteClient = () => composeSqliteClient(buildSharedDeps(sqsQueues(sqsQueueUrlsFromEnv())));
@@ -2,11 +2,12 @@ import type { IAccountConfigRepository, IAccountExportRequestRepository, IAccoun
2
2
  import { BodySyncQueueService, BodySyncService, type FilterConfig, FlagQueueService, type IImapConnection, MailboxQueueService, MessageMoveService, OutboxAttachmentService, OutboxQueueService, SpamReportService } from "@remit/mailbox-service";
3
3
  import { type SecretsService } from "@remit/secrets-service";
4
4
  import type { StorageService } from "@remit/storage-service";
5
+ import type { QueueName, RemitClientQueues } from "./queues.js";
5
6
  export interface ConnectionScope {
6
7
  getConnection: () => Promise<IImapConnection>;
7
8
  disconnect: () => Promise<void>;
8
9
  }
9
- export interface RemitClient {
10
+ export interface RemitClientCore {
10
11
  accountConfig: IAccountConfigRepository;
11
12
  account: IAccountRepository;
12
13
  accountSetting: IAccountSettingRepository;
@@ -36,18 +37,29 @@ export interface RemitClient {
36
37
  calendarStore: ICalendarStore;
37
38
  storage: StorageService;
38
39
  secrets: SecretsService;
39
- bodySync: BodySyncService;
40
- bodySyncQueue?: BodySyncQueueService;
41
40
  placementMove: IMessagePlacementMoveRepository;
42
41
  flagPush: IMessageFlagPushRepository;
42
+ outboxAttachment: OutboxAttachmentService;
43
+ createConnectionScope: (accountId: string) => Promise<ConnectionScope>;
44
+ }
45
+ export interface SyncQueueServices {
43
46
  flagQueue: FlagQueueService;
44
47
  mailboxQueue: MailboxQueueService;
45
48
  messageMove: MessageMoveService;
46
- outboxAttachment: OutboxAttachmentService;
47
- outboxQueue: OutboxQueueService;
48
49
  spamReport: SpamReportService;
49
- createConnectionScope: (accountId: string) => Promise<ConnectionScope>;
50
50
  }
51
+ export interface SmtpQueueServices {
52
+ outboxQueue: OutboxQueueService;
53
+ }
54
+ export interface BodyQueueServices {
55
+ bodySyncQueue: BodySyncQueueService;
56
+ }
57
+ export interface ReadPathServices {
58
+ bodySync: BodySyncService;
59
+ }
60
+ export type RemitClient<K extends QueueName = QueueName> = RemitClientCore & {
61
+ queues: Pick<RemitClientQueues, K>;
62
+ } & ("sync" extends K ? SyncQueueServices : unknown) & ("smtp" extends K ? SmtpQueueServices : unknown) & ("body" extends K ? BodyQueueServices : unknown) & ("sync" | "messageManagement" extends K ? ReadPathServices : unknown);
51
63
  export interface RemitClientRepositories {
52
64
  accountConfig: IAccountConfigRepository;
53
65
  account: IAccountRepository;
@@ -83,13 +95,15 @@ export interface RemitClientRepositories {
83
95
  export interface RemitClientSharedDeps {
84
96
  storage: StorageService;
85
97
  secrets: SecretsService;
86
- sqsQueueUrl: string;
87
- sqsSmtpQueueUrl: string;
88
- bodySyncQueue?: BodySyncQueueService;
98
+ queues: RemitClientQueues;
89
99
  }
90
100
  export interface RemitClientDeps extends RemitClientSharedDeps {
91
101
  repositories: RemitClientRepositories;
92
102
  }
93
- export declare const buildSharedDeps: () => RemitClientSharedDeps;
94
- export declare const buildReadPathFilterConfig: (repositories: RemitClientRepositories) => FilterConfig | undefined;
103
+ export declare const buildReadPathFilterConfig: (repositories: Pick<RemitClientRepositories, "filter" | "messageLabel" | "mailbox" | "message" | "threadMessage" | "placementMove" | "address" | "mailboxSpecialUse">, queues: Pick<RemitClientQueues, "messageManagement">) => FilterConfig;
104
+ export declare const createRemitCore: (deps: Omit<RemitClientDeps, "queues">) => RemitClientCore;
105
+ export declare const composeSyncServices: (core: RemitClientCore, queues: Pick<RemitClientQueues, "sync">) => SyncQueueServices;
106
+ export declare const composeSmtpServices: (core: RemitClientCore, queues: Pick<RemitClientQueues, "smtp">) => SmtpQueueServices;
107
+ export declare const composeBodyServices: (queues: Pick<RemitClientQueues, "body">) => BodyQueueServices;
108
+ export declare const composeReadPathServices: (core: RemitClientCore, queues: Pick<RemitClientQueues, "messageManagement">, sync: Pick<SyncQueueServices, "flagQueue">) => ReadPathServices;
95
109
  export declare const createRemitClient: (deps: RemitClientDeps) => RemitClient;