@remit/backend 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/.env.test +7 -0
  2. package/dev-server/content-auth.test.ts +113 -0
  3. package/dev-server/content-auth.ts +54 -0
  4. package/dev-server/content-handler.test.ts +105 -0
  5. package/dev-server/content-handler.ts +79 -0
  6. package/dev-server/content-path.test.ts +37 -0
  7. package/dev-server/content-path.ts +27 -0
  8. package/dev-server/cors.test.ts +48 -0
  9. package/dev-server/cors.ts +25 -0
  10. package/dev-server/lambda-helpers.ts +69 -0
  11. package/dev-server/server.ts +289 -0
  12. package/package.json +82 -0
  13. package/src/auth.ts +92 -0
  14. package/src/config/msoauth.ts +60 -0
  15. package/src/data-backend.test.ts +25 -0
  16. package/src/data-backend.ts +20 -0
  17. package/src/derive/autoMoved.ts +28 -0
  18. package/src/derive/contentSignature.test.ts +153 -0
  19. package/src/derive/contentSignature.ts +139 -0
  20. package/src/derive/contentUrl.test.ts +156 -0
  21. package/src/derive/contentUrl.ts +70 -0
  22. package/src/derive/enrichThreadRows.ts +155 -0
  23. package/src/derive/filterThreadCriteria.test.ts +119 -0
  24. package/src/derive/filterThreadCriteria.ts +60 -0
  25. package/src/derive/pendingMoveCounts.ts +90 -0
  26. package/src/derive/senderTrust.test.ts +67 -0
  27. package/src/derive/senderTrust.ts +23 -0
  28. package/src/error.ts +55 -0
  29. package/src/handlers/account-guards.ts +137 -0
  30. package/src/handlers/account-oauth.test.ts +383 -0
  31. package/src/handlers/account-oauth.ts +452 -0
  32. package/src/handlers/account-overrides.test.ts +69 -0
  33. package/src/handlers/account-overrides.ts +286 -0
  34. package/src/handlers/account-ownership.ts +25 -0
  35. package/src/handlers/account-signature.ts +129 -0
  36. package/src/handlers/account.ts +524 -0
  37. package/src/handlers/address.ts +136 -0
  38. package/src/handlers/config.test.ts +184 -0
  39. package/src/handlers/config.ts +219 -0
  40. package/src/handlers/ensure-account-config.ts +30 -0
  41. package/src/handlers/filter.ts +294 -0
  42. package/src/handlers/folder-role-appointments.test.ts +250 -0
  43. package/src/handlers/folder-role-appointments.ts +272 -0
  44. package/src/handlers/folder-role.ts +76 -0
  45. package/src/handlers/index.ts +48 -0
  46. package/src/handlers/mailbox.ts +411 -0
  47. package/src/handlers/me.ts +190 -0
  48. package/src/handlers/message.ts +886 -0
  49. package/src/handlers/organize.test.ts +43 -0
  50. package/src/handlers/organize.ts +196 -0
  51. package/src/handlers/outbox.ts +218 -0
  52. package/src/handlers/search.test.ts +182 -0
  53. package/src/handlers/search.ts +105 -0
  54. package/src/handlers/sync-progress.test.ts +158 -0
  55. package/src/handlers/sync-progress.ts +64 -0
  56. package/src/handlers/sync.test.ts +146 -0
  57. package/src/handlers/sync.ts +119 -0
  58. package/src/handlers/thread.ts +361 -0
  59. package/src/handlers/unified-threads.ts +196 -0
  60. package/src/handlers/vip-suggestions.ts +21 -0
  61. package/src/index.ts +170 -0
  62. package/src/json.ts +11 -0
  63. package/src/jwt-auth.test.ts +89 -0
  64. package/src/jwt-auth.ts +95 -0
  65. package/src/request-context.test.ts +61 -0
  66. package/src/request-context.ts +29 -0
  67. package/src/request.ts +10 -0
  68. package/src/response.test.ts +107 -0
  69. package/src/response.ts +80 -0
  70. package/src/service/compose-postgres.ts +72 -0
  71. package/src/service/compose-sqlite.ts +80 -0
  72. package/src/service/create-remit-client.ts +358 -0
  73. package/src/service/dynamodb.test.ts +100 -0
  74. package/src/service/dynamodb.ts +58 -0
  75. package/src/service/filter.ts +55 -0
  76. package/src/service/fire-and-forget.test.ts +126 -0
  77. package/src/service/fire-and-forget.ts +79 -0
  78. package/src/service/localhost-env-config.test.ts +40 -0
  79. package/src/service/organize.test.ts +384 -0
  80. package/src/service/organize.ts +354 -0
  81. package/src/service/semantic-capability.test.ts +64 -0
  82. package/src/service/semantic-capability.ts +62 -0
  83. package/src/service/sqs.ts +4 -0
  84. package/src/service/trigger-sync.test.ts +211 -0
  85. package/src/service/trigger-sync.ts +102 -0
  86. package/src/types.ts +161 -0
  87. package/tsconfig.json +7 -0
@@ -0,0 +1,184 @@
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 { triggerConfigLoadSyncs } from "./config.js";
5
+
6
+ const QUEUE_URL = "http://localhost:9324/000000000000/remit-dev-mailboxes";
7
+
8
+ interface LogCall {
9
+ fields: Record<string, unknown>;
10
+ message: string;
11
+ }
12
+
13
+ const createLoggerSpy = () => {
14
+ const info: LogCall[] = [];
15
+ const error: LogCall[] = [];
16
+ const logger = {
17
+ info: (fields: Record<string, unknown>, message: string): void => {
18
+ info.push({ fields, message });
19
+ },
20
+ error: (fields: Record<string, unknown>, message: string): void => {
21
+ error.push({ fields, message });
22
+ },
23
+ };
24
+ return { info, error, logger };
25
+ };
26
+
27
+ const okSqsClient = (sent: SendMessageCommand[]): SQSClient =>
28
+ ({
29
+ send: async (cmd: SendMessageCommand) => {
30
+ sent.push(cmd);
31
+ return {};
32
+ },
33
+ }) as unknown as SQSClient;
34
+
35
+ const failingSqsClient = (error: Error): SQSClient =>
36
+ ({
37
+ send: async () => {
38
+ throw error;
39
+ },
40
+ }) as unknown as SQSClient;
41
+
42
+ describe("triggerConfigLoadSyncs", () => {
43
+ it("enqueues a sync for each account and logs success", async () => {
44
+ const sent: SendMessageCommand[] = [];
45
+ const { logger, info, error } = createLoggerSpy();
46
+
47
+ await triggerConfigLoadSyncs("config-1", ["acc-a", "acc-b"], {
48
+ sqsClient: okSqsClient(sent),
49
+ queueUrl: QUEUE_URL,
50
+ logger,
51
+ });
52
+
53
+ assert.equal(sent.length, 2);
54
+ assert.equal(info.length, 2);
55
+ assert.equal(error.length, 0);
56
+ });
57
+
58
+ it("does NOT reject when the SQS enqueue fails (read stays resilient)", async () => {
59
+ const econnrefused = Object.assign(new Error(""), {
60
+ name: "AggregateError",
61
+ code: "ECONNREFUSED",
62
+ });
63
+ const { logger } = createLoggerSpy();
64
+
65
+ await assert.doesNotReject(
66
+ triggerConfigLoadSyncs("config-1", ["acc-a"], {
67
+ sqsClient: failingSqsClient(econnrefused),
68
+ queueUrl: QUEUE_URL,
69
+ logger,
70
+ }),
71
+ );
72
+ });
73
+
74
+ it("logs the failure loudly with the alertable structured fields", async () => {
75
+ const econnrefused = Object.assign(new Error(""), {
76
+ name: "AggregateError",
77
+ code: "ECONNREFUSED",
78
+ });
79
+ const { logger, error } = createLoggerSpy();
80
+
81
+ await triggerConfigLoadSyncs("config-1", ["acc-a"], {
82
+ sqsClient: failingSqsClient(econnrefused),
83
+ queueUrl: QUEUE_URL,
84
+ logger,
85
+ });
86
+
87
+ assert.equal(error.length, 1);
88
+ const call = error[0];
89
+ if (!call) throw new Error("expected an error log");
90
+ assert.equal(call.fields.alert, "sync_trigger_failed");
91
+ assert.equal(call.fields.source, "config_load");
92
+ assert.equal(call.fields.accountId, "acc-a");
93
+ assert.equal(call.fields.accountConfigId, "config-1");
94
+ assert.equal(call.fields.errorName, "AggregateError");
95
+ assert.equal(call.fields.errorCode, "ECONNREFUSED");
96
+ });
97
+
98
+ it("isolates failures per account — one bad enqueue does not block the others", async () => {
99
+ const sent: SendMessageCommand[] = [];
100
+ let calls = 0;
101
+ const sqsClient = {
102
+ send: async (cmd: SendMessageCommand) => {
103
+ calls += 1;
104
+ if (calls === 1)
105
+ throw Object.assign(new Error(""), { code: "ECONNREFUSED" });
106
+ sent.push(cmd);
107
+ return {};
108
+ },
109
+ } as unknown as SQSClient;
110
+ const { logger, info, error } = createLoggerSpy();
111
+
112
+ await triggerConfigLoadSyncs("config-1", ["acc-a", "acc-b"], {
113
+ sqsClient,
114
+ queueUrl: QUEUE_URL,
115
+ logger,
116
+ });
117
+
118
+ assert.equal(error.length, 1);
119
+ assert.equal(info.length, 1);
120
+ assert.equal(sent.length, 1);
121
+ });
122
+
123
+ it("reads SDK error name from the .Code field when present", async () => {
124
+ const sdkError = Object.assign(new Error("boom"), {
125
+ name: "QueueDoesNotExist",
126
+ Code: "AWS.SimpleQueueService.NonExistentQueue",
127
+ });
128
+ const { logger, error } = createLoggerSpy();
129
+
130
+ await triggerConfigLoadSyncs("config-1", ["acc-a"], {
131
+ sqsClient: failingSqsClient(sdkError),
132
+ queueUrl: QUEUE_URL,
133
+ logger,
134
+ });
135
+
136
+ const call = error[0];
137
+ if (!call) throw new Error("expected an error log");
138
+ assert.equal(
139
+ call.fields.errorCode,
140
+ "AWS.SimpleQueueService.NonExistentQueue",
141
+ );
142
+ });
143
+
144
+ // The actual smoke-storm failure mode: GET /config void-fires this trigger
145
+ // (it does not await it), the SQS queue is unreachable, and a concurrent read
146
+ // is in flight on the same event loop. The escaped rejection used to land on
147
+ // that read and 500 it. Assert nothing leaks even when fired exactly that way.
148
+ it("void-fired with an unreachable queue leaks NO unhandled rejection onto a concurrent read", async () => {
149
+ const leaked: unknown[] = [];
150
+ const onUnhandled = (reason: unknown): void => {
151
+ leaked.push(reason);
152
+ };
153
+ process.on("unhandledRejection", onUnhandled);
154
+
155
+ const econnrefused = Object.assign(new Error(""), {
156
+ name: "AggregateError",
157
+ code: "ECONNREFUSED",
158
+ });
159
+ const { logger } = createLoggerSpy();
160
+
161
+ let readResolved = false;
162
+ try {
163
+ // Fire-and-forget exactly as the getConfig handler does.
164
+ void triggerConfigLoadSyncs("config-1", ["acc-a", "acc-b"], {
165
+ sqsClient: failingSqsClient(econnrefused),
166
+ queueUrl: QUEUE_URL,
167
+ logger,
168
+ });
169
+
170
+ // A concurrent "read" sharing the event loop, like /mailboxes or /outbox.
171
+ await new Promise((resolve) => setImmediate(resolve)).then(() => {
172
+ readResolved = true;
173
+ });
174
+ // Drain remaining microtasks so any escaped rejection would surface.
175
+ await new Promise((resolve) => setImmediate(resolve));
176
+ await new Promise((resolve) => setImmediate(resolve));
177
+ } finally {
178
+ process.off("unhandledRejection", onUnhandled);
179
+ }
180
+
181
+ assert.equal(readResolved, true);
182
+ assert.equal(leaked.length, 0);
183
+ });
184
+ });
@@ -0,0 +1,219 @@
1
+ import type { SQSClient } from "@aws-sdk/client-sqs";
2
+ import type {
3
+ AccountConfigResponse,
4
+ ConfigDescriptionResponse,
5
+ } from "@remit/api-openapi-types";
6
+ import type { AccountConfigItem, MailboxItem } from "@remit/data-ports";
7
+ import { NotFoundError } from "@remit/data-ports/errors";
8
+ import { logger } from "@remit/logger-lambda";
9
+ import type { APIGatewayProxyEvent } from "aws-lambda";
10
+ import { env } from "expect-env";
11
+ import type { Context } from "openapi-backend";
12
+ import { getAccountConfigIdFromEvent, getSubFromEvent } from "../auth.js";
13
+ import { getClient } from "../service/dynamodb.js";
14
+ import { fireAndForget } from "../service/fire-and-forget.js";
15
+ import { sqsClient } from "../service/sqs.js";
16
+ import { triggerAccountSync } from "../service/trigger-sync.js";
17
+ import type { ConfigOperationIds, OperationHandler } from "../types.js";
18
+ import { toAccountResponse } from "./account-guards.js";
19
+ import {
20
+ type AccountOverrides,
21
+ groupAccountOverrides,
22
+ } from "./account-overrides.js";
23
+ import {
24
+ type AccountSignature,
25
+ groupSignaturesByAccount,
26
+ } from "./account-signature.js";
27
+ import {
28
+ groupFolderAppointmentsByAccount,
29
+ resolveFolderAppointments,
30
+ } from "./folder-role-appointments.js";
31
+
32
+ type StructuredLog = (fields: Record<string, unknown>, message: string) => void;
33
+
34
+ interface ConfigSyncTriggerDeps {
35
+ sqsClient: SQSClient;
36
+ queueUrl: string;
37
+ logger: { info: StructuredLog; error: StructuredLog };
38
+ }
39
+
40
+ const defaultConfigSyncTriggerDeps = (): ConfigSyncTriggerDeps => ({
41
+ sqsClient,
42
+ queueUrl: env.SQS_QUEUE_URL,
43
+ logger,
44
+ });
45
+
46
+ /**
47
+ * Fire-and-forget sync trigger for the accounts surfaced by GET /config.
48
+ *
49
+ * GET /config is a READ: its response is already computed and does not depend
50
+ * on the trigger succeeding. A failure here (the SQS queue unreachable in
51
+ * smoke/e2e, an IAM/SQS misconfig in prod) must therefore never reach the read
52
+ * response — but it also silently stops ALL sync, so it has to be loud and
53
+ * alertable rather than swallowed.
54
+ *
55
+ * This helper is fully self-contained: it catches every per-account rejection
56
+ * internally and resolves to void, so the caller can `void`-fire it with no
57
+ * chance of an unhandled rejection escaping. The dev/Lambda runtime shares one
58
+ * event loop, so an escaped rejection lands on whatever request is active when
59
+ * the deferred SQS retry finally fails — which is how a `/config` enqueue
60
+ * failure ended up 500-ing unrelated reads like `/outbox` and `/threads`.
61
+ *
62
+ * Each failure is logged as a distinct structured error carrying the SDK error
63
+ * name/code on dedicated fields plus a stable `alert: "sync_trigger_failed"`
64
+ * discriminator a CloudWatch metric filter / alarm can key off.
65
+ */
66
+ export const triggerConfigLoadSyncs = async (
67
+ accountConfigId: string,
68
+ accountIds: ReadonlyArray<string>,
69
+ deps: ConfigSyncTriggerDeps = defaultConfigSyncTriggerDeps(),
70
+ ): Promise<void> => {
71
+ await Promise.all(
72
+ accountIds.map((accountId) =>
73
+ fireAndForget(
74
+ async () => {
75
+ const { eventId } = await triggerAccountSync({
76
+ sqsClient: deps.sqsClient,
77
+ queueUrl: deps.queueUrl,
78
+ accountId,
79
+ });
80
+ deps.logger.info(
81
+ { accountId, eventId },
82
+ "Sync triggered on config load",
83
+ );
84
+ },
85
+ {
86
+ source: "config_load",
87
+ message: "Failed to trigger account sync on config load",
88
+ ids: { accountId, accountConfigId },
89
+ logger: deps.logger,
90
+ },
91
+ ),
92
+ ),
93
+ );
94
+ };
95
+
96
+ const toAccountConfigResponse = (
97
+ config: AccountConfigItem,
98
+ ): AccountConfigResponse => ({
99
+ accountConfigId: config.accountConfigId,
100
+ userId: config.userId,
101
+ name: config.name,
102
+ state: ((config as unknown as { state?: string }).state ??
103
+ "active") as AccountConfigResponse["state"],
104
+ createdAt: config.createdAt,
105
+ updatedAt: config.updatedAt,
106
+ });
107
+
108
+ /**
109
+ * Returned by GET /config when no AccountConfig row exists yet for this user.
110
+ * GET must never mutate state, so instead of creating a row we synthesize a
111
+ * stable, empty shape the frontend can render as a "no accounts yet" state.
112
+ * The POST account-creation flow will materialize the real row when the user
113
+ * adds their first account.
114
+ */
115
+ const emptyConfigResponse = (
116
+ accountConfigId: string,
117
+ event: APIGatewayProxyEvent,
118
+ ): ConfigDescriptionResponse => {
119
+ const now = Date.now();
120
+ const userId = getSubFromEvent(event) ?? accountConfigId;
121
+ return {
122
+ accountConfig: {
123
+ accountConfigId,
124
+ userId,
125
+ state: "active",
126
+ createdAt: now,
127
+ updatedAt: now,
128
+ },
129
+ accounts: [],
130
+ };
131
+ };
132
+
133
+ export const ConfigOperations: Record<
134
+ ConfigOperationIds,
135
+ OperationHandler<ConfigOperationIds>
136
+ > = {
137
+ ConfigOperations_getConfig: async (
138
+ _context: Context,
139
+ ...args: unknown[]
140
+ ): Promise<ConfigDescriptionResponse> => {
141
+ const event = args[0] as APIGatewayProxyEvent;
142
+ const accountConfigId = getAccountConfigIdFromEvent(event);
143
+ const client = await getClient();
144
+ const description = await client.accountConfig
145
+ .describe(accountConfigId)
146
+ .catch((err: unknown) => {
147
+ if (err instanceof NotFoundError) return undefined;
148
+ throw err;
149
+ });
150
+
151
+ if (!description || description.accountConfig.length === 0) {
152
+ return emptyConfigResponse(accountConfigId, event);
153
+ }
154
+
155
+ const accountConfig = description.accountConfig[0];
156
+ if (!accountConfig) {
157
+ return emptyConfigResponse(accountConfigId, event);
158
+ }
159
+
160
+ // Filter out deleted accounts (tombstone pattern)
161
+ const activeAccounts = description.account.filter(
162
+ (acc) => acc.deletedAt === undefined,
163
+ );
164
+
165
+ // Signatures and the display-name/mute overrides are stored per-account in
166
+ // AccountSetting rows (RFC 032). Load the whole set for this config once and
167
+ // key both by accountId so the account mapping below surfaces each without
168
+ // an N+1. The per-account folder-role map (RFC 032
169
+ // exclusive-folder-appointment, #976) rides the same settings rows, grouped
170
+ // the same way.
171
+ const allSettings =
172
+ await client.accountSetting.listByAccountConfig(accountConfigId);
173
+ const signaturesByAccount = groupSignaturesByAccount(allSettings);
174
+ const overridesByAccount = groupAccountOverrides(allSettings);
175
+ const appointmentsByAccount = groupFolderAppointmentsByAccount(allSettings);
176
+ const signatureOf = (accountId: string): AccountSignature =>
177
+ signaturesByAccount.get(accountId) ?? {};
178
+ const overridesOf = (accountId: string): AccountOverrides =>
179
+ overridesByAccount.get(accountId) ?? {};
180
+
181
+ // One mailbox list per account, fetched in parallel: `findFolderForRole`
182
+ // needs each account's folders to fill any role the user hasn't appointed
183
+ // yet (RFC 032: "the map is never empty for a normal provider").
184
+ const mailboxesByAccount = new Map<string, MailboxItem[]>(
185
+ await Promise.all(
186
+ activeAccounts.map(
187
+ async (acc): Promise<[string, MailboxItem[]]> => [
188
+ acc.accountId,
189
+ await client.mailbox.listAllByAccount(acc.accountId),
190
+ ],
191
+ ),
192
+ ),
193
+ );
194
+
195
+ // Fire-and-forget: a failed sync enqueue must never fail this read.
196
+ // triggerConfigLoadSyncs swallows nothing — it logs each failure loudly
197
+ // with an alertable structured field — but it also never rejects, so the
198
+ // `void` here cannot leak an unhandled rejection into a later request.
199
+ void triggerConfigLoadSyncs(
200
+ accountConfigId,
201
+ activeAccounts.map((acc) => acc.accountId),
202
+ );
203
+
204
+ return {
205
+ accountConfig: toAccountConfigResponse(accountConfig),
206
+ accounts: activeAccounts.map((acc) =>
207
+ toAccountResponse(
208
+ acc,
209
+ signatureOf(acc.accountId),
210
+ overridesOf(acc.accountId),
211
+ resolveFolderAppointments(
212
+ appointmentsByAccount.get(acc.accountId) ?? new Map(),
213
+ mailboxesByAccount.get(acc.accountId) ?? [],
214
+ ),
215
+ ),
216
+ ),
217
+ };
218
+ },
219
+ };
@@ -0,0 +1,30 @@
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
+ };