@remit/backend 0.0.143 → 0.0.145

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 (279) hide show
  1. package/dist/dev-server/content-handler.d.ts +38 -0
  2. package/dist/dev-server/content-handler.js +46 -0
  3. package/dist/src/api.d.ts +5 -0
  4. package/dist/src/api.js +73 -0
  5. package/dist/src/auth.d.ts +24 -0
  6. package/dist/src/auth.js +81 -0
  7. package/dist/src/config/api-config.d.ts +20 -0
  8. package/dist/src/config/api-config.js +1 -0
  9. package/dist/src/config/from-env.d.ts +2 -0
  10. package/dist/src/config/from-env.js +65 -0
  11. package/dist/src/config/msoauth-secrets-manager.d.ts +2 -0
  12. package/dist/src/config/msoauth-secrets-manager.js +28 -0
  13. package/dist/src/config/msoauth.d.ts +19 -0
  14. package/dist/src/config/msoauth.js +6 -0
  15. package/{src/derive/autoMoved.ts → dist/src/derive/autoMoved.d.ts} +1 -29
  16. package/dist/src/derive/autoMoved.js +49 -0
  17. package/dist/src/derive/contentSignature.d.ts +40 -0
  18. package/{src/derive/contentSignature.ts → dist/src/derive/contentSignature.js} +23 -77
  19. package/dist/src/derive/contentUrl.d.ts +33 -0
  20. package/dist/src/derive/contentUrl.js +27 -0
  21. package/{src/derive/deriveMuted.ts → dist/src/derive/deriveMuted.d.ts} +1 -3
  22. package/dist/src/derive/deriveMuted.js +7 -0
  23. package/dist/src/derive/enrichThreadRows.d.ts +118 -0
  24. package/dist/src/derive/enrichThreadRows.js +151 -0
  25. package/dist/src/derive/filterThreadCriteria.d.ts +23 -0
  26. package/dist/src/derive/filterThreadCriteria.js +24 -0
  27. package/{src/derive/pendingMoveCounts.ts → dist/src/derive/pendingMoveCounts.d.ts} +5 -53
  28. package/dist/src/derive/pendingMoveCounts.js +53 -0
  29. package/{src/derive/senderTrust.ts → dist/src/derive/senderTrust.d.ts} +1 -9
  30. package/dist/src/derive/senderTrust.js +19 -0
  31. package/dist/src/error.d.ts +21 -0
  32. package/dist/src/error.js +85 -0
  33. package/dist/src/handlers/account-guards.d.ts +36 -0
  34. package/dist/src/handlers/account-guards.js +107 -0
  35. package/dist/src/handlers/account-oauth.d.ts +15 -0
  36. package/dist/src/handlers/account-oauth.js +334 -0
  37. package/dist/src/handlers/account-overrides.d.ts +53 -0
  38. package/dist/src/handlers/account-overrides.js +233 -0
  39. package/dist/src/handlers/account-signature.d.ts +28 -0
  40. package/dist/src/handlers/account-signature.js +85 -0
  41. package/dist/src/handlers/account.d.ts +7 -0
  42. package/dist/src/handlers/account.js +337 -0
  43. package/dist/src/handlers/address.d.ts +35 -0
  44. package/dist/src/handlers/address.js +133 -0
  45. package/dist/src/handlers/calendar-event.d.ts +70 -0
  46. package/dist/src/handlers/calendar-event.js +417 -0
  47. package/dist/src/handlers/calendar-feed.d.ts +47 -0
  48. package/dist/src/handlers/calendar-feed.js +184 -0
  49. package/dist/src/handlers/calendar-suggestion.d.ts +47 -0
  50. package/dist/src/handlers/calendar-suggestion.js +204 -0
  51. package/dist/src/handlers/calendar.d.ts +116 -0
  52. package/dist/src/handlers/calendar.js +323 -0
  53. package/dist/src/handlers/config.d.ts +43 -0
  54. package/dist/src/handlers/config.js +164 -0
  55. package/dist/src/handlers/ensure-account-config.d.ts +9 -0
  56. package/dist/src/handlers/ensure-account-config.js +27 -0
  57. package/dist/src/handlers/filter.d.ts +3 -0
  58. package/dist/src/handlers/filter.js +88 -0
  59. package/dist/src/handlers/folder-role-appointments.d.ts +68 -0
  60. package/dist/src/handlers/folder-role-appointments.js +168 -0
  61. package/dist/src/handlers/folder-role-labels.d.ts +26 -0
  62. package/dist/src/handlers/folder-role-labels.js +40 -0
  63. package/dist/src/handlers/folder-role.d.ts +9 -0
  64. package/dist/src/handlers/folder-role.js +37 -0
  65. package/dist/src/handlers/index.d.ts +88 -0
  66. package/dist/src/handlers/index.js +62 -0
  67. package/dist/src/handlers/label.d.ts +43 -0
  68. package/dist/src/handlers/label.js +107 -0
  69. package/dist/src/handlers/mailbox-bindings.d.ts +41 -0
  70. package/dist/src/handlers/mailbox-bindings.js +61 -0
  71. package/dist/src/handlers/mailbox.d.ts +23 -0
  72. package/dist/src/handlers/mailbox.js +211 -0
  73. package/dist/src/handlers/me.d.ts +2 -0
  74. package/dist/src/handlers/me.js +117 -0
  75. package/dist/src/handlers/message.d.ts +82 -0
  76. package/dist/src/handlers/message.js +595 -0
  77. package/dist/src/handlers/organize.d.ts +10 -0
  78. package/dist/src/handlers/organize.js +103 -0
  79. package/dist/src/handlers/outbox-attachment.d.ts +25 -0
  80. package/dist/src/handlers/outbox-attachment.js +77 -0
  81. package/dist/src/handlers/outbox.d.ts +4 -0
  82. package/dist/src/handlers/outbox.js +136 -0
  83. package/dist/src/handlers/sync-progress.d.ts +46 -0
  84. package/dist/src/handlers/sync-progress.js +63 -0
  85. package/dist/src/handlers/sync.d.ts +28 -0
  86. package/dist/src/handlers/sync.js +73 -0
  87. package/dist/src/handlers/system-update.d.ts +2 -0
  88. package/dist/src/handlers/system-update.js +159 -0
  89. package/dist/src/handlers/thread.d.ts +148 -0
  90. package/dist/src/handlers/thread.js +228 -0
  91. package/dist/src/handlers/unified-threads.d.ts +247 -0
  92. package/dist/src/handlers/unified-threads.js +406 -0
  93. package/{src/handlers/vip-suggestions.ts → dist/src/handlers/vip-suggestions.d.ts} +1 -13
  94. package/dist/src/handlers/vip-suggestions.js +16 -0
  95. package/dist/src/index.d.ts +5 -0
  96. package/dist/src/index.js +53 -0
  97. package/{src/json.ts → dist/src/json.d.ts} +1 -4
  98. package/dist/src/json.js +10 -0
  99. package/dist/src/jwt-auth.d.ts +15 -0
  100. package/dist/src/jwt-auth.js +45 -0
  101. package/dist/src/lambda.d.ts +13 -0
  102. package/dist/src/lambda.js +33 -0
  103. package/dist/src/request.d.ts +9 -0
  104. package/dist/src/request.js +17 -0
  105. package/dist/src/response.d.ts +44 -0
  106. package/dist/src/response.js +80 -0
  107. package/dist/src/serve.d.ts +31 -0
  108. package/dist/src/serve.js +117 -0
  109. package/dist/src/service/account-deletion.d.ts +20 -0
  110. package/dist/src/service/account-deletion.js +33 -0
  111. package/dist/src/service/compose-sqlite.d.ts +3 -0
  112. package/dist/src/service/compose-sqlite.js +13 -0
  113. package/dist/src/service/create-remit-client.d.ts +109 -0
  114. package/dist/src/service/create-remit-client.js +188 -0
  115. package/dist/src/service/data-client.d.ts +12 -0
  116. package/dist/src/service/data-client.js +42 -0
  117. package/dist/src/service/export-identity.d.ts +4 -0
  118. package/dist/src/service/export-identity.js +33 -0
  119. package/dist/src/service/fire-and-forget.d.ts +54 -0
  120. package/dist/src/service/fire-and-forget.js +37 -0
  121. package/dist/src/service/organize.d.ts +181 -0
  122. package/dist/src/service/organize.js +367 -0
  123. package/dist/src/service/queues.d.ts +12 -0
  124. package/dist/src/service/queues.js +1 -0
  125. package/dist/src/service/refusal.d.ts +4 -0
  126. package/dist/src/service/refusal.js +20 -0
  127. package/dist/src/service/sender-category.d.ts +51 -0
  128. package/{src/service/sender-category.ts → dist/src/service/sender-category.js} +40 -81
  129. package/dist/src/service/shared-deps.d.ts +6 -0
  130. package/dist/src/service/shared-deps.js +24 -0
  131. package/dist/src/service/trigger-sync.d.ts +57 -0
  132. package/dist/src/service/trigger-sync.js +31 -0
  133. package/dist/src/types.d.ts +72 -0
  134. package/dist/src/types.js +1 -0
  135. package/package.json +125 -98
  136. package/.env.test +0 -4
  137. package/dev-server/content-auth.test.ts +0 -113
  138. package/dev-server/content-auth.ts +0 -54
  139. package/dev-server/content-handler.test.ts +0 -105
  140. package/dev-server/content-handler.ts +0 -79
  141. package/dev-server/content-path.test.ts +0 -37
  142. package/dev-server/content-path.ts +0 -27
  143. package/dev-server/cors.test.ts +0 -48
  144. package/dev-server/cors.ts +0 -25
  145. package/dev-server/keep-alive.test.ts +0 -24
  146. package/dev-server/lambda-helpers.ts +0 -69
  147. package/dev-server/log-output.test.ts +0 -173
  148. package/dev-server/oauth-callback-route.test.ts +0 -66
  149. package/dev-server/relational-health.test.ts +0 -83
  150. package/dev-server/relational-health.ts +0 -39
  151. package/dev-server/server.ts +0 -384
  152. package/dev-server/sync-age.test.ts +0 -91
  153. package/dev-server/sync-age.ts +0 -55
  154. package/dev-server/upload-handler.ts +0 -132
  155. package/dev-server/upload-route.test.ts +0 -196
  156. package/scripts/backfill-classification.ts +0 -91
  157. package/scripts/backfill-list-id.ts +0 -83
  158. package/scripts/config-save.ts +0 -128
  159. package/src/auth.test.ts +0 -12
  160. package/src/auth.ts +0 -92
  161. package/src/config/msoauth.test.ts +0 -48
  162. package/src/config/msoauth.ts +0 -48
  163. package/src/data-backend.test.ts +0 -23
  164. package/src/data-backend.ts +0 -14
  165. package/src/derive/autoMoved.test.ts +0 -104
  166. package/src/derive/contentSignature.test.ts +0 -161
  167. package/src/derive/contentUrl.test.ts +0 -172
  168. package/src/derive/contentUrl.ts +0 -86
  169. package/src/derive/deriveMuted.test.ts +0 -34
  170. package/src/derive/enrichThreadRows.test.ts +0 -360
  171. package/src/derive/enrichThreadRows.ts +0 -234
  172. package/src/derive/filterThreadCriteria.test.ts +0 -126
  173. package/src/derive/filterThreadCriteria.ts +0 -51
  174. package/src/derive/senderTrust.test.ts +0 -67
  175. package/src/error.test.ts +0 -201
  176. package/src/error.ts +0 -123
  177. package/src/handlers/account-guards.test.ts +0 -67
  178. package/src/handlers/account-guards.ts +0 -169
  179. package/src/handlers/account-oauth-callback.test.ts +0 -155
  180. package/src/handlers/account-oauth-consent.test.ts +0 -336
  181. package/src/handlers/account-oauth-start.test.ts +0 -191
  182. package/src/handlers/account-oauth.test.ts +0 -439
  183. package/src/handlers/account-oauth.ts +0 -635
  184. package/src/handlers/account-overrides.test.ts +0 -88
  185. package/src/handlers/account-overrides.ts +0 -323
  186. package/src/handlers/account-ownership.ts +0 -25
  187. package/src/handlers/account-signature.ts +0 -117
  188. package/src/handlers/account-synced-services.test.ts +0 -245
  189. package/src/handlers/account.ts +0 -552
  190. package/src/handlers/address.test.ts +0 -381
  191. package/src/handlers/address.ts +0 -200
  192. package/src/handlers/calendar-event.test.ts +0 -685
  193. package/src/handlers/calendar-event.ts +0 -738
  194. package/src/handlers/calendar-feed-validators.test.ts +0 -228
  195. package/src/handlers/calendar-feed.test.ts +0 -439
  196. package/src/handlers/calendar-feed.ts +0 -291
  197. package/src/handlers/calendar-sqlite-fixture.ts +0 -36
  198. package/src/handlers/calendar-suggestion.test.ts +0 -1269
  199. package/src/handlers/calendar-suggestion.ts +0 -425
  200. package/src/handlers/calendar.test.ts +0 -1080
  201. package/src/handlers/calendar.ts +0 -517
  202. package/src/handlers/config.test.ts +0 -245
  203. package/src/handlers/config.ts +0 -332
  204. package/src/handlers/ensure-account-config.ts +0 -30
  205. package/src/handlers/filter.test.ts +0 -644
  206. package/src/handlers/filter.ts +0 -518
  207. package/src/handlers/folder-role-appointments.test.ts +0 -438
  208. package/src/handlers/folder-role-appointments.ts +0 -249
  209. package/src/handlers/folder-role-labels.ts +0 -63
  210. package/src/handlers/folder-role.test.ts +0 -209
  211. package/src/handlers/folder-role.ts +0 -80
  212. package/src/handlers/index.ts +0 -83
  213. package/src/handlers/label.test.ts +0 -112
  214. package/src/handlers/label.ts +0 -210
  215. package/src/handlers/mailbox-bindings.ts +0 -111
  216. package/src/handlers/mailbox-rename-refusal.test.ts +0 -247
  217. package/src/handlers/mailbox-settled-target.test.ts +0 -251
  218. package/src/handlers/mailbox.test.ts +0 -280
  219. package/src/handlers/mailbox.ts +0 -581
  220. package/src/handlers/me.test.ts +0 -81
  221. package/src/handlers/me.ts +0 -203
  222. package/src/handlers/message-read-backfill.test.ts +0 -103
  223. package/src/handlers/message.test.ts +0 -131
  224. package/src/handlers/message.ts +0 -1131
  225. package/src/handlers/organize.test.ts +0 -277
  226. package/src/handlers/organize.ts +0 -197
  227. package/src/handlers/outbox-attachment.test.ts +0 -765
  228. package/src/handlers/outbox-attachment.ts +0 -126
  229. package/src/handlers/outbox-list.test.ts +0 -179
  230. package/src/handlers/outbox.test.ts +0 -772
  231. package/src/handlers/outbox.ts +0 -258
  232. package/src/handlers/search.test.ts +0 -161
  233. package/src/handlers/search.ts +0 -105
  234. package/src/handlers/sync-progress.test.ts +0 -205
  235. package/src/handlers/sync-progress.ts +0 -99
  236. package/src/handlers/sync.test.ts +0 -168
  237. package/src/handlers/sync.ts +0 -118
  238. package/src/handlers/system-update.test.ts +0 -497
  239. package/src/handlers/system-update.ts +0 -230
  240. package/src/handlers/thread.test.ts +0 -396
  241. package/src/handlers/thread.ts +0 -427
  242. package/src/handlers/unified-threads-mail-sources.sqlite.test.ts +0 -197
  243. package/src/handlers/unified-threads.test.ts +0 -785
  244. package/src/handlers/unified-threads.ts +0 -669
  245. package/src/index.test.ts +0 -83
  246. package/src/index.ts +0 -228
  247. package/src/jwt-auth.test.ts +0 -152
  248. package/src/jwt-auth.ts +0 -132
  249. package/src/log-redaction.test.ts +0 -144
  250. package/src/openapi-spec.test.ts +0 -118
  251. package/src/request-context.test.ts +0 -61
  252. package/src/request-context.ts +0 -33
  253. package/src/request-pipeline.test.ts +0 -88
  254. package/src/request.ts +0 -10
  255. package/src/response.test.ts +0 -130
  256. package/src/response.ts +0 -144
  257. package/src/service/compose-sqlite.ts +0 -98
  258. package/src/service/config-import.ts +0 -39
  259. package/src/service/create-remit-client.ts +0 -522
  260. package/src/service/data-client.test.ts +0 -97
  261. package/src/service/data-client.ts +0 -58
  262. package/src/service/export-identity.ts +0 -39
  263. package/src/service/filter.ts +0 -40
  264. package/src/service/fire-and-forget.test.ts +0 -126
  265. package/src/service/fire-and-forget.ts +0 -79
  266. package/src/service/localhost-env-config.test.ts +0 -36
  267. package/src/service/organize-queue.ts +0 -98
  268. package/src/service/organize.test.ts +0 -1451
  269. package/src/service/organize.ts +0 -1025
  270. package/src/service/read-path-filter-config.test.ts +0 -243
  271. package/src/service/semantic-capability.test.ts +0 -78
  272. package/src/service/semantic-capability.ts +0 -95
  273. package/src/service/sender-category.test.ts +0 -235
  274. package/src/service/sqs.ts +0 -4
  275. package/src/service/trigger-sync.test.ts +0 -205
  276. package/src/service/trigger-sync.ts +0 -100
  277. package/src/stdout-capture-fixture.ts +0 -40
  278. package/src/types.ts +0 -271
  279. package/tsconfig.json +0 -7
@@ -1,245 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import type { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
4
- import { AccountAuthType } from "@remit/domain-enums";
5
- import { triggerConfigLoadSyncs } from "./config.js";
6
-
7
- /**
8
- * The credential a password account stores. Its content is never read here —
9
- * only its presence decides whether a sync is worth enqueuing.
10
- */
11
- const withPassword = (accountId: string) => ({
12
- accountId,
13
- passwordHash: '{"ciphertext":"x"}',
14
- });
15
-
16
- const QUEUE_URL = "http://localhost:9324/000000000000/remit-dev-mailboxes";
17
-
18
- interface LogCall {
19
- fields: Record<string, unknown>;
20
- message: string;
21
- }
22
-
23
- const createLoggerSpy = () => {
24
- const info: LogCall[] = [];
25
- const error: LogCall[] = [];
26
- const logger = {
27
- info: (fields: Record<string, unknown>, message: string): void => {
28
- info.push({ fields, message });
29
- },
30
- error: (fields: Record<string, unknown>, message: string): void => {
31
- error.push({ fields, message });
32
- },
33
- };
34
- return { info, error, logger };
35
- };
36
-
37
- const okSqsClient = (sent: SendMessageCommand[]): SQSClient =>
38
- ({
39
- send: async (cmd: SendMessageCommand) => {
40
- sent.push(cmd);
41
- return {};
42
- },
43
- }) as unknown as SQSClient;
44
-
45
- const failingSqsClient = (error: Error): SQSClient =>
46
- ({
47
- send: async () => {
48
- throw error;
49
- },
50
- }) as unknown as SQSClient;
51
-
52
- describe("triggerConfigLoadSyncs", () => {
53
- it("enqueues a sync for each account and logs success", async () => {
54
- const sent: SendMessageCommand[] = [];
55
- const { logger, info, error } = createLoggerSpy();
56
-
57
- await triggerConfigLoadSyncs(
58
- "config-1",
59
- [withPassword("acc-a"), withPassword("acc-b")],
60
- {
61
- sqsClient: okSqsClient(sent),
62
- queueUrl: QUEUE_URL,
63
- logger,
64
- },
65
- );
66
-
67
- assert.equal(sent.length, 2);
68
- assert.equal(info.length, 2);
69
- assert.equal(error.length, 0);
70
- });
71
-
72
- it("skips an account that stores no credential (issue #1120)", async () => {
73
- const sent: SendMessageCommand[] = [];
74
- const { logger, error } = createLoggerSpy();
75
-
76
- await triggerConfigLoadSyncs(
77
- "config-1",
78
- [
79
- { accountId: "imported-no-password" },
80
- withPassword("acc-with-password"),
81
- ],
82
- { sqsClient: okSqsClient(sent), queueUrl: QUEUE_URL, logger },
83
- );
84
-
85
- assert.equal(sent.length, 1);
86
- assert.equal(error.length, 0);
87
- const body = JSON.parse(String(sent[0]?.input.MessageBody)) as {
88
- accountId: string;
89
- };
90
- assert.equal(body.accountId, "acc-with-password");
91
- });
92
-
93
- it("skips an OAuth account that stores no refresh token", async () => {
94
- const sent: SendMessageCommand[] = [];
95
- const { logger } = createLoggerSpy();
96
-
97
- await triggerConfigLoadSyncs(
98
- "config-1",
99
- [
100
- {
101
- accountId: "oauth-unconnected",
102
- authType: AccountAuthType.OauthMicrosoft,
103
- },
104
- ],
105
- { sqsClient: okSqsClient(sent), queueUrl: QUEUE_URL, logger },
106
- );
107
-
108
- assert.equal(sent.length, 0);
109
- });
110
-
111
- it("does NOT reject when the SQS enqueue fails (read stays resilient)", async () => {
112
- const econnrefused = Object.assign(new Error(""), {
113
- name: "AggregateError",
114
- code: "ECONNREFUSED",
115
- });
116
- const { logger } = createLoggerSpy();
117
-
118
- await assert.doesNotReject(
119
- triggerConfigLoadSyncs("config-1", [withPassword("acc-a")], {
120
- sqsClient: failingSqsClient(econnrefused),
121
- queueUrl: QUEUE_URL,
122
- logger,
123
- }),
124
- );
125
- });
126
-
127
- it("logs the failure loudly with the alertable structured fields", async () => {
128
- const econnrefused = Object.assign(new Error(""), {
129
- name: "AggregateError",
130
- code: "ECONNREFUSED",
131
- });
132
- const { logger, error } = createLoggerSpy();
133
-
134
- await triggerConfigLoadSyncs("config-1", [withPassword("acc-a")], {
135
- sqsClient: failingSqsClient(econnrefused),
136
- queueUrl: QUEUE_URL,
137
- logger,
138
- });
139
-
140
- assert.equal(error.length, 1);
141
- const call = error[0];
142
- if (!call) throw new Error("expected an error log");
143
- assert.equal(call.fields.alert, "sync_trigger_failed");
144
- assert.equal(call.fields.source, "config_load");
145
- assert.equal(call.fields.accountId, "acc-a");
146
- assert.equal(call.fields.accountConfigId, "config-1");
147
- assert.equal(call.fields.errorName, "AggregateError");
148
- assert.equal(call.fields.errorCode, "ECONNREFUSED");
149
- });
150
-
151
- it("isolates failures per account — one bad enqueue does not block the others", async () => {
152
- const sent: SendMessageCommand[] = [];
153
- let calls = 0;
154
- const sqsClient = {
155
- send: async (cmd: SendMessageCommand) => {
156
- calls += 1;
157
- if (calls === 1)
158
- throw Object.assign(new Error(""), { code: "ECONNREFUSED" });
159
- sent.push(cmd);
160
- return {};
161
- },
162
- } as unknown as SQSClient;
163
- const { logger, info, error } = createLoggerSpy();
164
-
165
- await triggerConfigLoadSyncs(
166
- "config-1",
167
- [withPassword("acc-a"), withPassword("acc-b")],
168
- {
169
- sqsClient,
170
- queueUrl: QUEUE_URL,
171
- logger,
172
- },
173
- );
174
-
175
- assert.equal(error.length, 1);
176
- assert.equal(info.length, 1);
177
- assert.equal(sent.length, 1);
178
- });
179
-
180
- it("reads SDK error name from the .Code field when present", async () => {
181
- const sdkError = Object.assign(new Error("boom"), {
182
- name: "QueueDoesNotExist",
183
- Code: "AWS.SimpleQueueService.NonExistentQueue",
184
- });
185
- const { logger, error } = createLoggerSpy();
186
-
187
- await triggerConfigLoadSyncs("config-1", [withPassword("acc-a")], {
188
- sqsClient: failingSqsClient(sdkError),
189
- queueUrl: QUEUE_URL,
190
- logger,
191
- });
192
-
193
- const call = error[0];
194
- if (!call) throw new Error("expected an error log");
195
- assert.equal(
196
- call.fields.errorCode,
197
- "AWS.SimpleQueueService.NonExistentQueue",
198
- );
199
- });
200
-
201
- // The actual smoke-storm failure mode: GET /config void-fires this trigger
202
- // (it does not await it), the SQS queue is unreachable, and a concurrent read
203
- // is in flight on the same event loop. The escaped rejection used to land on
204
- // that read and 500 it. Assert nothing leaks even when fired exactly that way.
205
- it("void-fired with an unreachable queue leaks NO unhandled rejection onto a concurrent read", async () => {
206
- const leaked: unknown[] = [];
207
- const onUnhandled = (reason: unknown): void => {
208
- leaked.push(reason);
209
- };
210
- process.on("unhandledRejection", onUnhandled);
211
-
212
- const econnrefused = Object.assign(new Error(""), {
213
- name: "AggregateError",
214
- code: "ECONNREFUSED",
215
- });
216
- const { logger } = createLoggerSpy();
217
-
218
- let readResolved = false;
219
- try {
220
- // Fire-and-forget exactly as the getConfig handler does.
221
- void triggerConfigLoadSyncs(
222
- "config-1",
223
- [withPassword("acc-a"), withPassword("acc-b")],
224
- {
225
- sqsClient: failingSqsClient(econnrefused),
226
- queueUrl: QUEUE_URL,
227
- logger,
228
- },
229
- );
230
-
231
- // A concurrent "read" sharing the event loop, like /mailboxes or /outbox.
232
- await new Promise((resolve) => setImmediate(resolve)).then(() => {
233
- readResolved = true;
234
- });
235
- // Drain remaining microtasks so any escaped rejection would surface.
236
- await new Promise((resolve) => setImmediate(resolve));
237
- await new Promise((resolve) => setImmediate(resolve));
238
- } finally {
239
- process.off("unhandledRejection", onUnhandled);
240
- }
241
-
242
- assert.equal(readResolved, true);
243
- assert.equal(leaked.length, 0);
244
- });
245
- });
@@ -1,332 +0,0 @@
1
- import type { SQSClient } from "@aws-sdk/client-sqs";
2
- import type {
3
- AccountConfigResponse,
4
- ConfigDescriptionResponse,
5
- ConfigImportReport,
6
- } from "@remit/api-openapi-types";
7
- import type { ReaderConfigDocument } from "@remit/config-format";
8
- import {
9
- importConfig,
10
- pendingImportOf,
11
- readConfigForExport,
12
- } from "@remit/config-transfer";
13
- import type { AccountConfigItem, MailboxItem } from "@remit/data-ports";
14
- import { ConfigNotEmptyError, NotFoundError } from "@remit/data-ports/errors";
15
- import type { CanonicalMailboxRoleValue } from "@remit/data-ports/folder-role";
16
- import { logger } from "@remit/logger-lambda";
17
- import {
18
- hasStoredCredential,
19
- type StoredCredentialFields,
20
- } from "@remit/mailbox-service/account-credentials";
21
- import {
22
- EMBEDDING_PROVIDER_OFF,
23
- readEmbeddingProviderFromEnv,
24
- } from "@remit/search-service/from-env";
25
- import type { APIGatewayProxyEvent } from "aws-lambda";
26
- import { env } from "expect-env";
27
- import type { Context } from "openapi-backend";
28
- import { getAccountConfigIdFromEvent, getSubFromEvent } from "../auth.js";
29
- import { embedAnchorText } from "../service/config-import.js";
30
- import { getClient } from "../service/data-client.js";
31
- import { exportIdentity } from "../service/export-identity.js";
32
- import { fireAndForget } from "../service/fire-and-forget.js";
33
- import { sqsClient } from "../service/sqs.js";
34
- import { triggerAccountSync } from "../service/trigger-sync.js";
35
- import type { ConfigOperationIds, OperationHandler } from "../types.js";
36
- import { toAccountResponse } from "./account-guards.js";
37
- import {
38
- type AccountOverrides,
39
- groupAccountOverrides,
40
- } from "./account-overrides.js";
41
- import {
42
- type AccountSignature,
43
- groupSignaturesByAccount,
44
- } from "./account-signature.js";
45
- import {
46
- groupFolderAppointmentsByAccount,
47
- resolveFolderAppointments,
48
- writeFolderRoleAppointment,
49
- } from "./folder-role-appointments.js";
50
-
51
- type StructuredLog = (fields: Record<string, unknown>, message: string) => void;
52
-
53
- interface ConfigSyncTriggerDeps {
54
- sqsClient: SQSClient;
55
- queueUrl: string;
56
- logger: { info: StructuredLog; error: StructuredLog };
57
- }
58
-
59
- const defaultConfigSyncTriggerDeps = (): ConfigSyncTriggerDeps => ({
60
- sqsClient,
61
- queueUrl: env.SQS_QUEUE_URL,
62
- logger,
63
- });
64
-
65
- /**
66
- * Fire-and-forget sync trigger for the accounts surfaced by GET /config.
67
- *
68
- * GET /config is a READ: its response is already computed and does not depend
69
- * on the trigger succeeding. A failure here (the SQS queue unreachable in
70
- * smoke/e2e, an IAM/SQS misconfig in prod) must therefore never reach the read
71
- * response — but it also silently stops ALL sync, so it has to be loud and
72
- * alertable rather than swallowed.
73
- *
74
- * This helper is fully self-contained: it catches every per-account rejection
75
- * internally and resolves to void, so the caller can `void`-fire it with no
76
- * chance of an unhandled rejection escaping. The dev/Lambda runtime shares one
77
- * event loop, so an escaped rejection lands on whatever request is active when
78
- * the deferred SQS retry finally fails — which is how a `/config` enqueue
79
- * failure ended up 500-ing unrelated reads like `/outbox` and `/threads`.
80
- *
81
- * Each failure is logged as a distinct structured error carrying the SDK error
82
- * name/code on dedicated fields plus a stable `alert: "sync_trigger_failed"`
83
- * discriminator a CloudWatch metric filter / alarm can key off.
84
- *
85
- * An account storing no credential is not enqueued at all (issue #1120): the
86
- * import wizard lands accounts without a password by design, and the client
87
- * polls this read while the user is still typing one. The guard is the stored
88
- * credential rather than `connectionState`, which stays `credentials_missing`
89
- * until a connect succeeds — see `hasStoredCredential`.
90
- */
91
- export const triggerConfigLoadSyncs = async (
92
- accountConfigId: string,
93
- accounts: ReadonlyArray<{ accountId: string } & StoredCredentialFields>,
94
- deps: ConfigSyncTriggerDeps = defaultConfigSyncTriggerDeps(),
95
- ): Promise<void> => {
96
- await Promise.all(
97
- accounts.filter(hasStoredCredential).map(({ accountId }) =>
98
- fireAndForget(
99
- async () => {
100
- const { eventId } = await triggerAccountSync({
101
- sqsClient: deps.sqsClient,
102
- queueUrl: deps.queueUrl,
103
- accountId,
104
- });
105
- deps.logger.info(
106
- { accountId, eventId },
107
- "Sync triggered on config load",
108
- );
109
- },
110
- {
111
- source: "config_load",
112
- message: "Failed to trigger account sync on config load",
113
- ids: { accountId, accountConfigId },
114
- logger: deps.logger,
115
- },
116
- ),
117
- ),
118
- );
119
- };
120
-
121
- const toAccountConfigResponse = (
122
- config: AccountConfigItem,
123
- ): AccountConfigResponse => ({
124
- accountConfigId: config.accountConfigId,
125
- userId: config.userId,
126
- name: config.name,
127
- state: ((config as unknown as { state?: string }).state ??
128
- "active") as AccountConfigResponse["state"],
129
- createdAt: config.createdAt,
130
- updatedAt: config.updatedAt,
131
- });
132
-
133
- /**
134
- * Returned by GET /config when no AccountConfig row exists yet for this user.
135
- * GET must never mutate state, so instead of creating a row we synthesize a
136
- * stable, empty shape the frontend can render as a "no accounts yet" state.
137
- * The POST account-creation flow will materialize the real row when the user
138
- * adds their first account.
139
- */
140
- const emptyConfigResponse = (
141
- accountConfigId: string,
142
- event: APIGatewayProxyEvent,
143
- ): ConfigDescriptionResponse => {
144
- const now = Date.now();
145
- const userId = getSubFromEvent(event) ?? accountConfigId;
146
- return {
147
- accountConfig: {
148
- accountConfigId,
149
- userId,
150
- state: "active",
151
- createdAt: now,
152
- updatedAt: now,
153
- },
154
- accounts: [],
155
- semanticSearchEnabled: semanticSearchEnabled(),
156
- };
157
- };
158
-
159
- /**
160
- * Whether this instance embeds anything, read from the same
161
- * `SEARCH_EMBEDDING_PROVIDER` the search-index worker and the `remit` wrapper
162
- * read. It rides GET /config because the semantic surfaces need it before they
163
- * have a query to send: an off instance stores no vectors, so the Organize
164
- * widen and semantic filters have nothing to read, and a client that learns
165
- * that only from an empty result cannot tell it from a mailbox with nothing
166
- * similar in it (#1068).
167
- */
168
- const semanticSearchEnabled = (): boolean =>
169
- readEmbeddingProviderFromEnv() !== EMBEDDING_PROVIDER_OFF;
170
-
171
- export const ConfigOperations: Record<
172
- ConfigOperationIds,
173
- OperationHandler<ConfigOperationIds>
174
- > = {
175
- ConfigOperations_getConfig: async (
176
- _context: Context,
177
- ...args: unknown[]
178
- ): Promise<ConfigDescriptionResponse> => {
179
- const event = args[0] as APIGatewayProxyEvent;
180
- const accountConfigId = getAccountConfigIdFromEvent(event);
181
- const client = await getClient();
182
- const description = await client.accountConfig
183
- .describe(accountConfigId)
184
- .catch((err: unknown) => {
185
- if (err instanceof NotFoundError) return undefined;
186
- throw err;
187
- });
188
-
189
- if (!description || description.accountConfig.length === 0) {
190
- return emptyConfigResponse(accountConfigId, event);
191
- }
192
-
193
- const accountConfig = description.accountConfig[0];
194
- if (!accountConfig) {
195
- return emptyConfigResponse(accountConfigId, event);
196
- }
197
-
198
- // Filter out deleted accounts (tombstone pattern)
199
- const activeAccounts = description.account.filter(
200
- (acc) => acc.deletedAt === undefined,
201
- );
202
-
203
- // Signatures and the display-name/mute overrides are stored per-account in
204
- // AccountSetting rows (RFC 032). Load the whole set for this config once and
205
- // key both by accountId so the account mapping below surfaces each without
206
- // an N+1. The per-account folder-role map (RFC 032
207
- // exclusive-folder-appointment, #976) rides the same settings rows, grouped
208
- // the same way.
209
- const allSettings =
210
- await client.accountSetting.listByAccountConfig(accountConfigId);
211
- const signaturesByAccount = groupSignaturesByAccount(allSettings);
212
- const overridesByAccount = groupAccountOverrides(allSettings);
213
- const appointmentsByAccount = groupFolderAppointmentsByAccount(allSettings);
214
- const signatureOf = (accountId: string): AccountSignature =>
215
- signaturesByAccount.get(accountId) ?? {};
216
- const overridesOf = (accountId: string): AccountOverrides =>
217
- overridesByAccount.get(accountId) ?? {};
218
-
219
- // One mailbox list per account, fetched in parallel: `findFolderForRole`
220
- // needs each account's folders to fill any role the user hasn't appointed
221
- // yet (RFC 032: "the map is never empty for a normal provider").
222
- const mailboxesByAccount = new Map<string, MailboxItem[]>(
223
- await Promise.all(
224
- activeAccounts.map(
225
- async (acc): Promise<[string, MailboxItem[]]> => [
226
- acc.accountId,
227
- await client.mailbox.listAllByAccount(acc.accountId),
228
- ],
229
- ),
230
- ),
231
- );
232
-
233
- // Fire-and-forget: a failed sync enqueue must never fail this read.
234
- // triggerConfigLoadSyncs swallows nothing — it logs each failure loudly
235
- // with an alertable structured field — but it also never rejects, so the
236
- // `void` here cannot leak an unhandled rejection into a later request.
237
- void triggerConfigLoadSyncs(accountConfigId, activeAccounts);
238
-
239
- // An import that named folders IMAP had not produced yet rides the config
240
- // read rather than a route of its own, so nothing has to poll for it.
241
- const pendingImport = pendingImportOf(
242
- await client.configImport.listByAccountConfig(accountConfigId),
243
- );
244
-
245
- return {
246
- accountConfig: toAccountConfigResponse(accountConfig),
247
- semanticSearchEnabled: semanticSearchEnabled(),
248
- ...(pendingImport ? { pendingImport } : {}),
249
- accounts: activeAccounts.map((acc) =>
250
- toAccountResponse(
251
- acc,
252
- signatureOf(acc.accountId),
253
- overridesOf(acc.accountId),
254
- resolveFolderAppointments(
255
- appointmentsByAccount.get(acc.accountId) ?? new Map(),
256
- mailboxesByAccount.get(acc.accountId) ?? [],
257
- ),
258
- ),
259
- ),
260
- };
261
- },
262
-
263
- ConfigOperations_exportConfig: async (
264
- _context: Context,
265
- ...args: unknown[]
266
- ): Promise<{ schemaVersion: number; document: ReaderConfigDocument }> => {
267
- const event = args[0] as APIGatewayProxyEvent;
268
- const accountConfigId = getAccountConfigIdFromEvent(event);
269
- const client = await getClient();
270
- const document = await readConfigForExport(
271
- client,
272
- accountConfigId,
273
- exportIdentity(),
274
- );
275
- return { schemaVersion: document.schemaVersion, document };
276
- },
277
-
278
- ConfigOperations_importConfig: async (
279
- context: Context,
280
- ...args: unknown[]
281
- ): Promise<ConfigImportReport> => {
282
- const event = args[0] as APIGatewayProxyEvent;
283
- const accountConfigId = getAccountConfigIdFromEvent(event);
284
- const body = (context.request.requestBody ?? {}) as {
285
- mode?: "validate" | "apply";
286
- onExisting?: "abort" | "merge";
287
- document?: unknown;
288
- };
289
- const client = await getClient();
290
-
291
- const outcome = await importConfig(
292
- {
293
- repositories: client,
294
- // Passed through as-is, undefined included: a backend with no
295
- // cross-entity transaction writes without one, and the report words
296
- // what survived a failure from whether this is here.
297
- transaction: client.writeSet,
298
- appointFolderRole: (
299
- configId,
300
- accountId,
301
- role,
302
- mailboxId,
303
- lastKnownPath,
304
- ) =>
305
- writeFolderRoleAppointment(
306
- client.accountSetting,
307
- configId,
308
- accountId,
309
- role as CanonicalMailboxRoleValue,
310
- mailboxId,
311
- lastKnownPath,
312
- ),
313
- embedAnchor: embedAnchorText,
314
- },
315
- {
316
- accountConfigId,
317
- userId: getSubFromEvent(event) ?? accountConfigId,
318
- document: body.document,
319
- mode: body.mode ?? "validate",
320
- onExisting: body.onExisting ?? "abort",
321
- },
322
- );
323
-
324
- if (outcome.outcome === "conflict") {
325
- throw new ConfigNotEmptyError(
326
- outcome.conflict.message,
327
- outcome.conflict.details,
328
- );
329
- }
330
- return outcome.report;
331
- },
332
- };
@@ -1,30 +0,0 @@
1
- import type { IAccountConfigRepository } from "@remit/data-ports";
2
- import { ConflictError, NotFoundError } from "@remit/data-ports/errors";
3
-
4
- /**
5
- * Ensure an AccountConfig row exists for the given id. A first-time caller
6
- * coming from Cognito will not yet have one; subsequent calls are idempotent.
7
- *
8
- * We attempt a read first to avoid relying on a race on `create()` — the
9
- * deterministic id (derived from Cognito `sub`) makes both paths safe.
10
- */
11
- export const ensureAccountConfig = async (
12
- accountConfig: IAccountConfigRepository,
13
- accountConfigId: string,
14
- ): Promise<void> => {
15
- const existing = await accountConfig.get(accountConfigId).catch((err) => {
16
- if (err instanceof NotFoundError) return undefined;
17
- throw err;
18
- });
19
- if (existing) return;
20
-
21
- await accountConfig
22
- .create({
23
- accountConfigId,
24
- userId: accountConfigId,
25
- })
26
- .catch((err) => {
27
- if (err instanceof ConflictError) return;
28
- throw err;
29
- });
30
- };