@remit/imap-worker 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 (54) hide show
  1. package/README.md +100 -0
  2. package/build.mjs +17 -0
  3. package/package.json +50 -0
  4. package/src/account-check.test.ts +122 -0
  5. package/src/account-check.ts +88 -0
  6. package/src/body-sync-gate.test.ts +185 -0
  7. package/src/body-sync-gate.ts +86 -0
  8. package/src/cli.ts +211 -0
  9. package/src/connection-scope.test.ts +266 -0
  10. package/src/connection-scope.ts +335 -0
  11. package/src/e2e-processor-shim.ts +248 -0
  12. package/src/emit.test.ts +44 -0
  13. package/src/emit.ts +142 -0
  14. package/src/events.ts +221 -0
  15. package/src/handlers/append-sent-message.ts +163 -0
  16. package/src/handlers/delete-account-objects.test.ts +81 -0
  17. package/src/handlers/delete-account-objects.ts +116 -0
  18. package/src/handlers/empty-trash.ts +136 -0
  19. package/src/handlers/flag-push.test.ts +25 -0
  20. package/src/handlers/flag-push.ts +224 -0
  21. package/src/handlers/mailbox-management.ts +266 -0
  22. package/src/handlers/mailbox-sync-order.test.ts +93 -0
  23. package/src/handlers/mailbox-sync-order.ts +65 -0
  24. package/src/handlers/message-copy.ts +219 -0
  25. package/src/handlers/message-delete.test.ts +176 -0
  26. package/src/handlers/message-delete.ts +283 -0
  27. package/src/handlers/message-move.test.ts +168 -0
  28. package/src/handlers/message-move.ts +298 -0
  29. package/src/handlers/placement-move-push.test.ts +234 -0
  30. package/src/handlers/placement-move-push.ts +434 -0
  31. package/src/handlers/sync-mailboxes.ts +241 -0
  32. package/src/handlers/sync-message-body.test.ts +375 -0
  33. package/src/handlers/sync-message-body.ts +337 -0
  34. package/src/handlers/sync-messages-deleted-account.test.ts +141 -0
  35. package/src/handlers/sync-messages.test.ts +204 -0
  36. package/src/handlers/sync-messages.ts +412 -0
  37. package/src/handlers/sync-reserved-host.test.ts +97 -0
  38. package/src/index.test.ts +22 -0
  39. package/src/index.ts +70 -0
  40. package/src/poller.ts +49 -0
  41. package/src/processor.test.ts +58 -0
  42. package/src/processor.ts +66 -0
  43. package/src/scheduler/config.test.ts +40 -0
  44. package/src/scheduler/config.ts +52 -0
  45. package/src/scheduler/decide-due.test.ts +44 -0
  46. package/src/scheduler/decide-due.ts +26 -0
  47. package/src/scheduler/handler.ts +52 -0
  48. package/src/scheduler/local-runner.ts +76 -0
  49. package/src/scheduler/run-tick.test.ts +248 -0
  50. package/src/scheduler/run-tick.ts +141 -0
  51. package/src/with-oauth-lifecycle-deps.ts +62 -0
  52. package/src/with-oauth-lifecycle.test.ts +227 -0
  53. package/src/with-oauth-lifecycle.ts +125 -0
  54. package/tsconfig.json +8 -0
package/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # @remit/imap-worker
2
+
3
+ SQS-driven Lambda worker for handling IMAP lifecycle events in the Remit system.
4
+
5
+ ## Features
6
+
7
+ - **Event Driven**: Processes events from SQS (`SYNC_MAILBOXES`, `SYNC_MESSAGES`, etc.)
8
+ - **CLI Tool**: Includes a CLI for manually triggering events during development
9
+ - **Bundled**: Built with esbuild for optimal Lambda performance
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install
15
+ npm run bundle
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ### Lambda Handler
21
+
22
+ The package exports a standard AWS Lambda SQS handler:
23
+
24
+ ```typescript
25
+ import { handler } from "@remit/imap-worker";
26
+ ```
27
+
28
+ ### CLI (Local Development)
29
+
30
+ Trigger events manually using the included CLI:
31
+
32
+ ```bash
33
+ # Sync all mailboxes for an account
34
+ npm run cli -- -t SYNC_MAILBOXES -a <accountId>
35
+
36
+ # Sync messages in a mailbox
37
+ npm run cli -- -t SYNC_MESSAGES -a <accountId> -m <mailboxId>
38
+
39
+ # Force full sync (ignore lastSyncUid)
40
+ npm run cli -- -t SYNC_MESSAGES -a <accountId> -m <mailboxId> --fullSync
41
+
42
+ # Sync message bodies for specific messages
43
+ npm run cli -- -t SYNC_MESSAGE_BODY -a <accountId> -m <mailboxId> --messageIds id1,id2,id3
44
+ ```
45
+
46
+ ## Environment Variables
47
+
48
+ | Variable | Required | Description |
49
+ | ----------------------------- | -------- | -------------------------------------------------------- |
50
+ | `DYNAMODB_TABLE_NAME` | Yes | Name of the DynamoDB table for Remit data |
51
+ | `SQS_QUEUE_URL_MAILBOXES` | Yes | FIFO queue URL for `SYNC_MAILBOXES` events |
52
+ | `SQS_QUEUE_URL_MESSAGES` | Yes | FIFO queue URL for `SYNC_MESSAGES` events |
53
+ | `SQS_QUEUE_URL_BODY` | Yes | FIFO queue URL for `SYNC_MESSAGE_BODY` events |
54
+ | `SQS_QUEUE_URL_FLAGS` | Yes | FIFO queue URL for `SYNC_FLAGS` events |
55
+ | `SQS_QUEUE_URL_MAILBOX_MGMT` | Yes | Standard queue URL for mailbox management events |
56
+ | `SQS_QUEUE_URL_MESSAGE_MGMT` | Yes | Standard queue URL for message management events |
57
+ | `S3_BUCKET` | Yes | S3 bucket for storing raw message content |
58
+ | `NODE_ENV` | No | Set to `development` for local execution |
59
+ | `LOG_LEVEL` | No | Logging level (default: `info`) |
60
+
61
+ ## Event Types
62
+
63
+ | Event | Description | Required Fields |
64
+ | ------------------- | ---------------------------------------------- | --------------------------------------- |
65
+ | `SYNC_MAILBOXES` | Discovers and syncs mailboxes for an account | `accountId` |
66
+ | `SYNC_MESSAGES` | Fetches new messages for a mailbox (one batch) | `accountId`, `mailboxId` |
67
+ | `SYNC_MESSAGE_BODY` | Fetches and stores message bodies in batch | `accountId`, `mailboxId`, `messageIds` |
68
+
69
+ ### Event Schema
70
+
71
+ All events share a base schema:
72
+
73
+ ```typescript
74
+ interface BaseEvent {
75
+ accountId: string;
76
+ eventId: string; // Idempotency key
77
+ timestamp: number; // Unix timestamp
78
+ }
79
+ ```
80
+
81
+ ## Architecture
82
+
83
+ ```
84
+ SQS Queue
85
+
86
+
87
+ ┌───────────────┐
88
+ │ Lambda/CLI │
89
+ │ (index.ts) │
90
+ └───────┬───────┘
91
+
92
+
93
+ ┌───────────────┐
94
+ │ processor │ ─── Routes events to handlers
95
+ └───────┬───────┘
96
+
97
+ ├──► syncMailboxes
98
+ ├──► syncMessages
99
+ └──► syncMessageBody
100
+ ```
package/build.mjs ADDED
@@ -0,0 +1,17 @@
1
+ import * as esbuild from "esbuild";
2
+
3
+ await esbuild.build({
4
+ entryPoints: ["src/index.ts", "src/cli.ts"],
5
+ bundle: true,
6
+ platform: "node",
7
+ target: "node20",
8
+ format: "esm",
9
+ outdir: "dist",
10
+ sourcemap: true,
11
+ external: [
12
+ "@aws-sdk/*", // Use Lambda-provided SDK
13
+ ],
14
+ banner: {
15
+ js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);",
16
+ },
17
+ });
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@remit/imap-worker",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "remit-worker": "./dist/cli.js"
8
+ },
9
+ "scripts": {
10
+ "bundle": "esbuild src/index.ts --sourcemap --bundle --platform=node --format=esm --outfile=dist/index.js",
11
+ "cli": "node --env-file=../../localhost-dev-aws.env src/cli.ts",
12
+ "test:typecheck": "tsgo --noEmit",
13
+ "test:run": "node --env-file=../../localhost-test-unit.env --test 'src/**/*.test.ts'",
14
+ "test": "npm run test:typecheck && npm run test:run"
15
+ },
16
+ "devDependencies": {
17
+ "@aws-sdk/client-sqs": "*",
18
+ "@aws-sdk/client-ssm": "*",
19
+ "@aws-sdk/core": "*",
20
+ "@remit/backend": "*",
21
+ "@remit/data-ports": "*",
22
+ "@remit/domain-enums": "*",
23
+ "@remit/logger-lambda": "*",
24
+ "@remit/mail-oauth-service": "*",
25
+ "@remit/mailbox-service": "*",
26
+ "@remit/search-index-worker": "*",
27
+ "@remit/secrets-service": "*",
28
+ "@remit/sqs-client": "*",
29
+ "@remit/storage-service": "*",
30
+ "@types/aws-lambda": "*",
31
+ "@types/nodemailer": "*",
32
+ "aws-sdk-client-mock": "*",
33
+ "esbuild": "^0.27.7",
34
+ "expect-env": "*",
35
+ "p-map": "*"
36
+ },
37
+ "dependencies": {
38
+ "@aws-sdk/client-s3": "^3.1055.0",
39
+ "nodemailer": "^9.0.3"
40
+ },
41
+ "license": "MIT",
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/remit-mail/remit.git",
48
+ "directory": "packages/imap-worker"
49
+ }
50
+ }
@@ -0,0 +1,122 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { AccountItem } from "@remit/data-ports";
4
+ import {
5
+ isAccountDeleted,
6
+ isAccountReauthRequired,
7
+ isReservedHost,
8
+ isUnsyncableHost,
9
+ } from "./account-check.js";
10
+
11
+ const log = {
12
+ info: () => {},
13
+ warn: () => {},
14
+ error: () => {},
15
+ debug: () => {},
16
+ } as unknown as Parameters<typeof isAccountDeleted>[1];
17
+
18
+ const buildAccount = (overrides: Partial<AccountItem> = {}): AccountItem =>
19
+ ({
20
+ accountId: "acct-1",
21
+ connectionState: "authenticated",
22
+ ...overrides,
23
+ }) as unknown as AccountItem;
24
+
25
+ describe("isAccountDeleted", () => {
26
+ it("returns false when deletedAt is absent", () => {
27
+ assert.equal(isAccountDeleted(buildAccount(), log), false);
28
+ });
29
+
30
+ it("returns true when deletedAt is set", () => {
31
+ assert.equal(
32
+ isAccountDeleted(buildAccount({ deletedAt: Date.now() }), log),
33
+ true,
34
+ );
35
+ });
36
+ });
37
+
38
+ describe("isReservedHost", () => {
39
+ const reserved = [
40
+ "invalid",
41
+ "example",
42
+ "imap.invalid",
43
+ "foo.invalid",
44
+ "server.invalid",
45
+ "SERVER.INVALID",
46
+ "mail.example",
47
+ "foo.example",
48
+ " imap.invalid ",
49
+ ];
50
+ for (const host of reserved) {
51
+ it(`returns true for reserved host ${JSON.stringify(host)}`, () => {
52
+ assert.equal(isReservedHost(host), true);
53
+ });
54
+ }
55
+
56
+ // localhost / .localhost / .test are legitimately used by local & e2e envs,
57
+ // so they must NOT be skipped (issue #835 follow-up).
58
+ const real = [
59
+ "imap.hostnet.nl",
60
+ "imap.gmail.com",
61
+ "localhost",
62
+ "foo.localhost",
63
+ "foo.test",
64
+ "notlocalhost",
65
+ "invalid.com",
66
+ "example.com",
67
+ "test.org",
68
+ ];
69
+ for (const host of real) {
70
+ it(`returns false for real host ${JSON.stringify(host)}`, () => {
71
+ assert.equal(isReservedHost(host), false);
72
+ });
73
+ }
74
+ });
75
+
76
+ describe("isUnsyncableHost", () => {
77
+ it("returns false for a resolvable host", () => {
78
+ assert.equal(
79
+ isUnsyncableHost(buildAccount({ imapHost: "imap.gmail.com" }), log),
80
+ false,
81
+ );
82
+ });
83
+
84
+ it("returns true for a reserved host", () => {
85
+ assert.equal(
86
+ isUnsyncableHost(buildAccount({ imapHost: "imap.invalid" }), log),
87
+ true,
88
+ );
89
+ });
90
+ });
91
+
92
+ describe("isAccountReauthRequired", () => {
93
+ it("returns false for authenticated accounts", () => {
94
+ assert.equal(
95
+ isAccountReauthRequired(
96
+ buildAccount({ connectionState: "authenticated" }),
97
+ log,
98
+ ),
99
+ false,
100
+ );
101
+ });
102
+
103
+ it("returns false for not_authenticated accounts", () => {
104
+ assert.equal(
105
+ isAccountReauthRequired(
106
+ buildAccount({ connectionState: "not_authenticated" }),
107
+ log,
108
+ ),
109
+ false,
110
+ );
111
+ });
112
+
113
+ it("returns true when connectionState is reauth_required", () => {
114
+ assert.equal(
115
+ isAccountReauthRequired(
116
+ buildAccount({ connectionState: "reauth_required" }),
117
+ log,
118
+ ),
119
+ true,
120
+ );
121
+ });
122
+ });
@@ -0,0 +1,88 @@
1
+ import type { AccountItem } from "@remit/data-ports";
2
+ import type { Logger } from "@remit/logger-lambda";
3
+
4
+ /**
5
+ * RFC 2606 reserved placeholder namespaces that are guaranteed never to resolve
6
+ * and are not used by any local/dev/e2e environment: `.invalid` (the actual
7
+ * smoke-test placeholder) and `.example`. An account pointed at one of these can
8
+ * never connect, so syncing it would retry and dead-letter forever — we skip it.
9
+ *
10
+ * `localhost`/`.localhost` and `.test` are deliberately NOT skipped: the e2e and
11
+ * mailfuzz suites run their IMAP server on `localhost`, and `.test` is commonly
12
+ * used for local testing.
13
+ */
14
+ const RESERVED_NAMES = ["invalid", "example"] as const;
15
+ const RESERVED_HOST_SUFFIXES = RESERVED_NAMES.map((name) => `.${name}`);
16
+
17
+ /**
18
+ * True when `host` is a reserved placeholder name that can never resolve.
19
+ *
20
+ * Matches the bare `invalid`/`example` and any subdomain of those namespaces
21
+ * (`mail.example`, `foo.invalid`, …). Suffix matching is anchored on the dot so
22
+ * real hosts like `invalid.com` or `imap.gmail.com` are NOT treated as reserved.
23
+ */
24
+ export const isReservedHost = (host: string): boolean => {
25
+ const normalized = host.trim().toLowerCase();
26
+ if (RESERVED_NAMES.includes(normalized as (typeof RESERVED_NAMES)[number])) {
27
+ return true;
28
+ }
29
+ return RESERVED_HOST_SUFFIXES.some((suffix) => normalized.endsWith(suffix));
30
+ };
31
+
32
+ /**
33
+ * Check if an account's IMAP host can never resolve (reserved TLD).
34
+ * Returns true if the account should be skipped cleanly — no connection
35
+ * attempt, no thrown error, so the event is acked rather than dead-lettered.
36
+ */
37
+ export const isUnsyncableHost = (
38
+ account: AccountItem,
39
+ log: Logger,
40
+ ): boolean => {
41
+ if (isReservedHost(account.imapHost)) {
42
+ log.warn(
43
+ { accountId: account.accountId, imapHost: account.imapHost },
44
+ "Skipping account: IMAP host is a reserved, never-resolvable name",
45
+ );
46
+ return true;
47
+ }
48
+ return false;
49
+ };
50
+
51
+ /**
52
+ * Check if an account is deleted (tombstone pattern).
53
+ * Returns true if the account should be skipped.
54
+ */
55
+ export const isAccountDeleted = (
56
+ account: AccountItem,
57
+ log: Logger,
58
+ ): boolean => {
59
+ if (account.deletedAt) {
60
+ log.info(
61
+ { accountId: account.accountId, deletedAt: account.deletedAt },
62
+ "Skipping deleted account",
63
+ );
64
+ return true;
65
+ }
66
+ return false;
67
+ };
68
+
69
+ /**
70
+ * Check if an account requires re-authentication (e.g. OAuth token revoked).
71
+ * Returns true if the account should be skipped until the user re-auths.
72
+ */
73
+ export const isAccountReauthRequired = (
74
+ account: AccountItem,
75
+ log: Logger,
76
+ ): boolean => {
77
+ if (account.connectionState === "reauth_required") {
78
+ log.info(
79
+ {
80
+ accountId: account.accountId,
81
+ connectionState: account.connectionState,
82
+ },
83
+ "Skipping account: reauth required",
84
+ );
85
+ return true;
86
+ }
87
+ return false;
88
+ };
@@ -0,0 +1,185 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import {
4
+ GetParameterCommand,
5
+ ParameterNotFound,
6
+ SSMClient,
7
+ } from "@aws-sdk/client-ssm";
8
+ import type { Logger } from "@remit/logger-lambda";
9
+ import { mockClient } from "aws-sdk-client-mock";
10
+ import { isBodySyncEnabled, resetBodySyncGateCache } from "./body-sync-gate.js";
11
+
12
+ const parameterName = "/dev/Remit/bodySyncEnabled";
13
+
14
+ interface CapturedWarn {
15
+ args: unknown[];
16
+ }
17
+
18
+ const createCapturingLogger = (): {
19
+ log: Logger;
20
+ warnCalls: CapturedWarn[];
21
+ } => {
22
+ const warnCalls: CapturedWarn[] = [];
23
+ const noop = () => {};
24
+ const log = {
25
+ info: noop,
26
+ warn: (...args: unknown[]) => warnCalls.push({ args }),
27
+ error: noop,
28
+ debug: noop,
29
+ fatal: noop,
30
+ trace: noop,
31
+ child: () => log,
32
+ } as unknown as Logger;
33
+ return { log, warnCalls };
34
+ };
35
+
36
+ const warnMessage = (warn: CapturedWarn): string => String(warn.args[1]);
37
+
38
+ describe("isBodySyncEnabled", () => {
39
+ beforeEach(() => {
40
+ resetBodySyncGateCache();
41
+ });
42
+
43
+ afterEach(() => {
44
+ mockClient(SSMClient).reset();
45
+ resetBodySyncGateCache();
46
+ });
47
+
48
+ it("returns true when the parameter is 'true'", async () => {
49
+ const ssmMock = mockClient(SSMClient);
50
+ ssmMock
51
+ .on(GetParameterCommand, { Name: parameterName })
52
+ .resolves({ Parameter: { Value: "true" } });
53
+ const { log } = createCapturingLogger();
54
+
55
+ const enabled = await isBodySyncEnabled(
56
+ parameterName,
57
+ log,
58
+ ssmMock as unknown as SSMClient,
59
+ );
60
+
61
+ assert.equal(enabled, true);
62
+ });
63
+
64
+ it("returns false when the parameter is 'false'", async () => {
65
+ const ssmMock = mockClient(SSMClient);
66
+ ssmMock
67
+ .on(GetParameterCommand, { Name: parameterName })
68
+ .resolves({ Parameter: { Value: "false" } });
69
+ const { log } = createCapturingLogger();
70
+
71
+ const enabled = await isBodySyncEnabled(
72
+ parameterName,
73
+ log,
74
+ ssmMock as unknown as SSMClient,
75
+ );
76
+
77
+ assert.equal(enabled, false);
78
+ });
79
+
80
+ it("treats casing and whitespace as disabled for ' FALSE '", async () => {
81
+ const ssmMock = mockClient(SSMClient);
82
+ ssmMock
83
+ .on(GetParameterCommand, { Name: parameterName })
84
+ .resolves({ Parameter: { Value: " FALSE " } });
85
+ const { log } = createCapturingLogger();
86
+
87
+ const enabled = await isBodySyncEnabled(
88
+ parameterName,
89
+ log,
90
+ ssmMock as unknown as SSMClient,
91
+ );
92
+
93
+ assert.equal(enabled, false);
94
+ });
95
+
96
+ it("fails open with a warning when the parameter does not exist", async () => {
97
+ const ssmMock = mockClient(SSMClient);
98
+ ssmMock
99
+ .on(GetParameterCommand, { Name: parameterName })
100
+ .rejects(new ParameterNotFound({ message: "not found", $metadata: {} }));
101
+ const { log, warnCalls } = createCapturingLogger();
102
+
103
+ const enabled = await isBodySyncEnabled(
104
+ parameterName,
105
+ log,
106
+ ssmMock as unknown as SSMClient,
107
+ );
108
+
109
+ assert.equal(enabled, true);
110
+ assert.equal(warnCalls.length, 1);
111
+ assert.match(warnMessage(warnCalls[0]), /failing open/i);
112
+ });
113
+
114
+ it("fails open with a warning when the parameter has no value", async () => {
115
+ const ssmMock = mockClient(SSMClient);
116
+ ssmMock
117
+ .on(GetParameterCommand, { Name: parameterName })
118
+ .resolves({ Parameter: {} });
119
+ const { log, warnCalls } = createCapturingLogger();
120
+
121
+ const enabled = await isBodySyncEnabled(
122
+ parameterName,
123
+ log,
124
+ ssmMock as unknown as SSMClient,
125
+ );
126
+
127
+ assert.equal(enabled, true);
128
+ assert.equal(warnCalls.length, 1);
129
+ assert.match(warnMessage(warnCalls[0]), /missing a value/i);
130
+ });
131
+
132
+ it("caches the result within the TTL — second call does not re-fetch", async () => {
133
+ const ssmMock = mockClient(SSMClient);
134
+ ssmMock
135
+ .on(GetParameterCommand, { Name: parameterName })
136
+ .resolves({ Parameter: { Value: "false" } });
137
+ const { log } = createCapturingLogger();
138
+
139
+ const first = await isBodySyncEnabled(
140
+ parameterName,
141
+ log,
142
+ ssmMock as unknown as SSMClient,
143
+ );
144
+ const second = await isBodySyncEnabled(
145
+ parameterName,
146
+ log,
147
+ ssmMock as unknown as SSMClient,
148
+ );
149
+
150
+ assert.equal(first, false);
151
+ assert.equal(second, false);
152
+ assert.equal(ssmMock.commandCalls(GetParameterCommand).length, 1);
153
+ });
154
+
155
+ it("coalesces concurrent cold-cache calls into a single GetParameter", async () => {
156
+ const ssmMock = mockClient(SSMClient);
157
+ let resolveSend:
158
+ | ((value: { Parameter: { Value: string } }) => void)
159
+ | undefined;
160
+ ssmMock.on(GetParameterCommand, { Name: parameterName }).callsFake(
161
+ () =>
162
+ new Promise((resolve) => {
163
+ resolveSend = resolve;
164
+ }),
165
+ );
166
+ const { log } = createCapturingLogger();
167
+
168
+ const a = isBodySyncEnabled(
169
+ parameterName,
170
+ log,
171
+ ssmMock as unknown as SSMClient,
172
+ );
173
+ const b = isBodySyncEnabled(
174
+ parameterName,
175
+ log,
176
+ ssmMock as unknown as SSMClient,
177
+ );
178
+
179
+ assert.ok(resolveSend, "GetParameter should have been invoked");
180
+ resolveSend?.({ Parameter: { Value: "true" } });
181
+
182
+ assert.deepEqual(await Promise.all([a, b]), [true, true]);
183
+ assert.equal(ssmMock.commandCalls(GetParameterCommand).length, 1);
184
+ });
185
+ });
@@ -0,0 +1,86 @@
1
+ import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
2
+ import type { Logger } from "@remit/logger-lambda";
3
+
4
+ const CACHE_TTL_MS = 30_000;
5
+
6
+ interface CacheEntry {
7
+ readonly enabled: boolean;
8
+ readonly expiresAt: number;
9
+ }
10
+
11
+ let cache: CacheEntry | undefined;
12
+ let inFlight: Promise<boolean> | undefined;
13
+ let defaultClient: SSMClient | undefined;
14
+
15
+ const getDefaultClient = (): SSMClient => {
16
+ if (!defaultClient) {
17
+ defaultClient = new SSMClient({});
18
+ }
19
+ return defaultClient;
20
+ };
21
+
22
+ /**
23
+ * Test seam — clears the module-scope TTL cache between unit tests.
24
+ */
25
+ export const resetBodySyncGateCache = (): void => {
26
+ cache = undefined;
27
+ inFlight = undefined;
28
+ };
29
+
30
+ const readParameter = async (
31
+ ssm: SSMClient,
32
+ parameterName: string,
33
+ log: Logger,
34
+ ): Promise<boolean> => {
35
+ const result = await ssm.send(
36
+ new GetParameterCommand({ Name: parameterName }),
37
+ );
38
+ const value = result.Parameter?.Value;
39
+
40
+ if (value === undefined) {
41
+ log.warn(
42
+ { parameterName },
43
+ "Body-sync toggle parameter missing a value, failing open (enabled)",
44
+ );
45
+ return true;
46
+ }
47
+
48
+ return value.trim().toLowerCase() !== "false";
49
+ };
50
+
51
+ /**
52
+ * Reads the `/{stage}/Remit/bodySyncEnabled` SSM parameter, cached at module
53
+ * scope for a short TTL. Returns `true` (enabled) when the parameter is absent
54
+ * or unreadable — body sync fails open so a missing toggle never halts prod.
55
+ */
56
+ export const isBodySyncEnabled = async (
57
+ parameterName: string,
58
+ log: Logger,
59
+ ssm: SSMClient = getDefaultClient(),
60
+ ): Promise<boolean> => {
61
+ if (cache && cache.expiresAt > Date.now()) {
62
+ return cache.enabled;
63
+ }
64
+
65
+ if (inFlight) {
66
+ return inFlight;
67
+ }
68
+
69
+ inFlight = readParameter(ssm, parameterName, log)
70
+ .catch((error) => {
71
+ log.warn(
72
+ { parameterName, error },
73
+ "Failed to read body-sync toggle parameter, failing open (enabled)",
74
+ );
75
+ return true;
76
+ })
77
+ .then((enabled) => {
78
+ cache = { enabled, expiresAt: Date.now() + CACHE_TTL_MS };
79
+ return enabled;
80
+ })
81
+ .finally(() => {
82
+ inFlight = undefined;
83
+ });
84
+
85
+ return inFlight;
86
+ };