@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,411 @@
1
+ import type {
2
+ MailboxResponse,
3
+ RenameMailboxInput,
4
+ } from "@remit/api-openapi-types";
5
+ import type { IAccountSettingRepository, MailboxItem } from "@remit/data-ports";
6
+ import { ForbiddenError, NotFoundError } from "@remit/data-ports/errors";
7
+ import { MessageSystemFlag } from "@remit/domain-enums";
8
+ import type { APIGatewayProxyEvent } from "aws-lambda";
9
+ import { getAccountConfigIdFromEvent } from "../auth.js";
10
+ import {
11
+ applyPendingMoveCountPrediction,
12
+ type PendingUnseenFlagPush,
13
+ } from "../derive/pendingMoveCounts.js";
14
+ import { getClient } from "../service/dynamodb.js";
15
+ import type {
16
+ MailboxDetailOperationIds,
17
+ MailboxOperationIds,
18
+ OperationHandler,
19
+ TrashOperationIds,
20
+ } from "../types.js";
21
+ import {
22
+ applyMailboxOverrideChanges,
23
+ loadMailboxOverrides,
24
+ loadMailboxOverridesForConfig,
25
+ type MailboxOverrides,
26
+ } from "./account-overrides.js";
27
+ import { assertAccountOwnership } from "./account-ownership.js";
28
+
29
+ /**
30
+ * The mute flag and the display-name override are user preferences that live
31
+ * in per-mailbox AccountSetting rows (RFC 032), not on the Mailbox entity.
32
+ * Pick only those keys from a PATCH body: each follows the same `null` → remove,
33
+ * value → set, absent/undefined → no-op semantics as UpdateAddressFlagsInput.
34
+ * The canonical role a folder fills is appointed separately — see
35
+ * FolderRoleOperations.appointFolderRole (RFC 032 exclusive-folder-appointment).
36
+ */
37
+ export const pickMailboxOverrideChanges = (
38
+ body: RenameMailboxInput,
39
+ ): {
40
+ displayNameOverride?: string | null;
41
+ muted?: RenameMailboxInput["muted"];
42
+ } => {
43
+ const changes: {
44
+ displayNameOverride?: string | null;
45
+ muted?: RenameMailboxInput["muted"];
46
+ } = {};
47
+ if (Object.hasOwn(body, "displayNameOverride")) {
48
+ changes.displayNameOverride = body.displayNameOverride;
49
+ }
50
+ if (Object.hasOwn(body, "muted")) {
51
+ changes.muted = body.muted;
52
+ }
53
+ return changes;
54
+ };
55
+
56
+ /**
57
+ * Minimal client surface needed to apply a mailbox PATCH. Structurally
58
+ * satisfied by RemitClient; narrowed so tests can stub it.
59
+ */
60
+ export interface MailboxPatchClient {
61
+ mailbox: {
62
+ get(accountId: string, mailboxId: string): Promise<MailboxItem>;
63
+ };
64
+ mailboxQueue: {
65
+ renameMailbox(
66
+ mailboxId: string,
67
+ newPath: string,
68
+ accountId: string,
69
+ ): Promise<MailboxItem>;
70
+ };
71
+ accountSetting: Pick<IAccountSettingRepository, "upsert" | "delete">;
72
+ }
73
+
74
+ /**
75
+ * Apply a mailbox PATCH body: override changes first (mute flag + display-name/
76
+ * role overrides — written to per-mailbox AccountSetting rows, no IMAP
77
+ * machinery), then rename — which triggers the IMAP rename machinery
78
+ * (syncStatus/oldPath + MAILBOX_RENAME event) — only when `fullPath` is
79
+ * present. An override-only PATCH therefore never calls
80
+ * `mailboxQueue.renameMailbox`.
81
+ */
82
+ export const applyMailboxPatch = async (
83
+ client: MailboxPatchClient,
84
+ accountConfigId: string,
85
+ mailboxId: string,
86
+ accountId: string,
87
+ body: RenameMailboxInput,
88
+ ): Promise<MailboxItem> => {
89
+ const { fullPath } = body;
90
+
91
+ // --- Override settings (mute flag + display-name/role overrides) ---
92
+ // Written to per-mailbox AccountSetting rows (RFC 032) with the same
93
+ // null→remove semantics as UpdateAddressFlagsInput. Applied before (and
94
+ // independent of) any rename so an override-only PATCH never touches
95
+ // syncStatus/oldPath.
96
+ const changes = pickMailboxOverrideChanges(body);
97
+ if (Object.keys(changes).length > 0) {
98
+ await applyMailboxOverrideChanges(
99
+ client.accountSetting,
100
+ accountConfigId,
101
+ mailboxId,
102
+ changes,
103
+ );
104
+ }
105
+
106
+ // --- Rename (IMAP machinery) ---
107
+ // Only triggered when fullPath is present.
108
+ if (!fullPath) {
109
+ return client.mailbox.get(accountId, mailboxId);
110
+ }
111
+
112
+ return client.mailboxQueue.renameMailbox(mailboxId, fullPath, accountId);
113
+ };
114
+
115
+ /**
116
+ * Cross-account guard for a mailbox reached by id under an `accountId` path.
117
+ * `assertAccountOwnership` only proves the caller owns the account in the path;
118
+ * the `mailboxId` in that same path can belong to a different account, so without
119
+ * this check a caller could read or mutate another account's mailbox. Mirrors the
120
+ * read/act split of `assertAccountOwnership`: 404 on a read (no existence leak),
121
+ * 403 on an action.
122
+ */
123
+ export const assertMailboxInAccount = (
124
+ mailbox: Pick<MailboxItem, "mailboxId" | "accountId">,
125
+ accountId: string,
126
+ mode: "read" | "act",
127
+ ): void => {
128
+ if (mailbox.accountId === accountId) return;
129
+ if (mode === "read") {
130
+ throw new NotFoundError(`Mailbox not found: ${mailbox.mailboxId}`);
131
+ }
132
+ throw new ForbiddenError(`Mailbox ${mailbox.mailboxId} not in account`);
133
+ };
134
+
135
+ /**
136
+ * Every pending placement move (issue #1271) for an account, on whichever
137
+ * backend is active (`RemitClient.placementMove` is present on both — see
138
+ * `create-remit-client.ts`). Read-only; feeds
139
+ * `applyPendingMoveCountPrediction`'s read-time adjustment only, never mutates
140
+ * stored counts (epic #1281 invariant 4). Markers are written by the
141
+ * imap-worker bulk sync path through `RemitClient.placementMove` on every
142
+ * backend, so this is a real signal on Postgres too.
143
+ */
144
+ const loadPendingMoves = (
145
+ client: Awaited<ReturnType<typeof getClient>>,
146
+ accountId: string,
147
+ ) => client.placementMove.listByAccountId(accountId);
148
+
149
+ /**
150
+ * Every pending `\Seen` flag-push marker (issue #1273) for an account —
151
+ * `\Flagged` (star) markers are excluded, since only read/unread state feeds
152
+ * `unseenCount`'s prediction. `RemitClient.flagPush` is present and WRITTEN
153
+ * on both backends, so this is a real signal on Postgres too.
154
+ */
155
+ const loadPendingUnseenFlagPushes = async (
156
+ client: Awaited<ReturnType<typeof getClient>>,
157
+ accountId: string,
158
+ ): Promise<PendingUnseenFlagPush[]> => {
159
+ const pushes = await client.flagPush.listByAccountId(accountId);
160
+ return pushes
161
+ .filter((push) => push.flagName === MessageSystemFlag.Seen)
162
+ .map((push) => ({ mailboxId: push.mailboxId, operation: push.operation }));
163
+ };
164
+
165
+ const toMailboxResponse = (
166
+ mailbox: MailboxItem,
167
+ overrides: MailboxOverrides = {},
168
+ ): MailboxResponse => ({
169
+ mailboxId: mailbox.mailboxId,
170
+ accountId: mailbox.accountId,
171
+ namespaceType: mailbox.namespaceType,
172
+ namespacePrefix: mailbox.namespacePrefix,
173
+ hierarchyDelimiter: mailbox.hierarchyDelimiter,
174
+ fullPath: mailbox.fullPath,
175
+ messageCount: mailbox.messageCount,
176
+ unseenCount: mailbox.unseenCount,
177
+ deletedCount: mailbox.deletedCount,
178
+ specialUse: mailbox.specialUse ? Array.from(mailbox.specialUse) : undefined,
179
+ lastSyncUid: mailbox.lastSyncUid,
180
+ highWaterMarkUid: mailbox.highWaterMarkUid,
181
+ lastMessageSyncAt: mailbox.lastMessageSyncAt,
182
+ muted: overrides.muted,
183
+ displayNameOverride: overrides.displayNameOverride,
184
+ createdAt: mailbox.createdAt,
185
+ updatedAt: mailbox.updatedAt,
186
+ });
187
+
188
+ export const MailboxOperations: Record<
189
+ MailboxOperationIds,
190
+ OperationHandler<MailboxOperationIds>
191
+ > = {
192
+ MailboxOperations_listMailboxes: async (context, ...args: unknown[]) => {
193
+ const event = args[0] as APIGatewayProxyEvent;
194
+ const accountConfigId = getAccountConfigIdFromEvent(event);
195
+ const { accountId } = context.request.params as { accountId: string };
196
+ const { continuationToken } = context.request.query as {
197
+ continuationToken?: string;
198
+ };
199
+
200
+ const client = await getClient();
201
+ const account = await client.account.get(accountId);
202
+ assertAccountOwnership(account, accountConfigId, "read");
203
+
204
+ const result = await client.mailbox.listByAccount(accountId, {
205
+ continuationToken,
206
+ });
207
+
208
+ // Overrides (mute / display-name / role) live in per-mailbox AccountSetting
209
+ // rows (RFC 032). Load the whole config's set in one query and key it by
210
+ // mailboxId so each mailbox surfaces its overrides without an N+1.
211
+ const overridesByMailbox = await loadMailboxOverridesForConfig(
212
+ client.accountSetting,
213
+ accountConfigId,
214
+ );
215
+
216
+ // Read-time prediction for any pending placement move (issue #1271) and
217
+ // pending \Seen flag push (issue #1273) — adjusts messageCount /
218
+ // unseenCount only, never the stored row (epic #1281 invariant 4).
219
+ const pendingMoves = await loadPendingMoves(client, accountId);
220
+ const pendingUnseenFlagPushes = await loadPendingUnseenFlagPushes(
221
+ client,
222
+ accountId,
223
+ );
224
+ const items = applyPendingMoveCountPrediction(
225
+ result.items,
226
+ pendingMoves,
227
+ pendingUnseenFlagPushes,
228
+ );
229
+
230
+ return {
231
+ items: items.map((mailbox) =>
232
+ toMailboxResponse(
233
+ mailbox,
234
+ overridesByMailbox.get(mailbox.mailboxId) ?? {},
235
+ ),
236
+ ),
237
+ continuationToken: result.continuationToken,
238
+ };
239
+ },
240
+
241
+ MailboxOperations_createMailbox: async (context, ...args: unknown[]) => {
242
+ const event = args[0] as APIGatewayProxyEvent;
243
+ const accountConfigId = getAccountConfigIdFromEvent(event);
244
+ const { accountId } = context.request.params as { accountId: string };
245
+ const { namespaceType, fullPath } = context.request.requestBody as {
246
+ namespaceType: string;
247
+ fullPath: string;
248
+ };
249
+
250
+ const client = await getClient();
251
+ const account = await client.account.get(accountId);
252
+ assertAccountOwnership(account, accountConfigId, "act");
253
+
254
+ const mailbox = await client.mailboxQueue.createMailbox(
255
+ {
256
+ accountId,
257
+ namespaceType: namespaceType as "personal" | "other_users" | "shared",
258
+ namespacePrefix: "",
259
+ hierarchyDelimiter: "/",
260
+ fullPath,
261
+ uidValidity: 0,
262
+ uidNext: 1,
263
+ highestModseq: 0,
264
+ messageCount: 0,
265
+ unseenCount: 0,
266
+ deletedCount: 0,
267
+ totalSize: 0,
268
+ lastSyncUid: 0,
269
+ highWaterMarkUid: 0,
270
+ lastMessageSyncAt: 0,
271
+ },
272
+ accountId,
273
+ true,
274
+ );
275
+
276
+ return toMailboxResponse(mailbox);
277
+ },
278
+ };
279
+
280
+ export const MailboxDetailOperations: Record<
281
+ MailboxDetailOperationIds,
282
+ OperationHandler<MailboxDetailOperationIds>
283
+ > = {
284
+ MailboxDetailOperations_getMailbox: async (context, ...args: unknown[]) => {
285
+ const event = args[0] as APIGatewayProxyEvent;
286
+ const accountConfigId = getAccountConfigIdFromEvent(event);
287
+ const { accountId, mailboxId } = context.request.params as {
288
+ accountId: string;
289
+ mailboxId: string;
290
+ };
291
+
292
+ const client = await getClient();
293
+ const account = await client.account.get(accountId);
294
+ assertAccountOwnership(account, accountConfigId, "read");
295
+
296
+ const mailbox = await client.mailbox.get(accountId, mailboxId);
297
+ assertMailboxInAccount(mailbox, accountId, "read");
298
+ const overrides = await loadMailboxOverrides(
299
+ client.accountSetting,
300
+ accountConfigId,
301
+ mailboxId,
302
+ );
303
+
304
+ // Read-time prediction for any pending placement move (issue #1271) and
305
+ // pending \Seen flag push (issue #1273) — adjusts messageCount /
306
+ // unseenCount only, never the stored row (epic #1281 invariant 4).
307
+ const pendingMoves = await loadPendingMoves(client, accountId);
308
+ const pendingUnseenFlagPushes = await loadPendingUnseenFlagPushes(
309
+ client,
310
+ accountId,
311
+ );
312
+ const adjusted =
313
+ applyPendingMoveCountPrediction(
314
+ [mailbox],
315
+ pendingMoves,
316
+ pendingUnseenFlagPushes,
317
+ )[0] ?? mailbox;
318
+
319
+ return toMailboxResponse(adjusted, overrides);
320
+ },
321
+
322
+ MailboxDetailOperations_renameMailbox: async (
323
+ context,
324
+ ...args: unknown[]
325
+ ) => {
326
+ const event = args[0] as APIGatewayProxyEvent;
327
+ const accountConfigId = getAccountConfigIdFromEvent(event);
328
+ const { accountId, mailboxId } = context.request.params as {
329
+ accountId: string;
330
+ mailboxId: string;
331
+ };
332
+ const body = context.request.requestBody as RenameMailboxInput;
333
+
334
+ const client = await getClient();
335
+ const account = await client.account.get(accountId);
336
+ assertAccountOwnership(account, accountConfigId, "act");
337
+
338
+ const existing = await client.mailbox.get(accountId, mailboxId);
339
+ assertMailboxInAccount(existing, accountId, "act");
340
+
341
+ const mailbox = await applyMailboxPatch(
342
+ client,
343
+ accountConfigId,
344
+ mailboxId,
345
+ accountId,
346
+ body,
347
+ );
348
+ const overrides = await loadMailboxOverrides(
349
+ client.accountSetting,
350
+ accountConfigId,
351
+ mailboxId,
352
+ );
353
+ return toMailboxResponse(mailbox, overrides);
354
+ },
355
+
356
+ MailboxDetailOperations_deleteMailbox: async (
357
+ context,
358
+ ...args: unknown[]
359
+ ) => {
360
+ const event = args[0] as APIGatewayProxyEvent;
361
+ const accountConfigId = getAccountConfigIdFromEvent(event);
362
+ const { accountId, mailboxId } = context.request.params as {
363
+ accountId: string;
364
+ mailboxId: string;
365
+ };
366
+
367
+ const client = await getClient();
368
+ const account = await client.account.get(accountId);
369
+ assertAccountOwnership(account, accountConfigId, "act");
370
+
371
+ const mailbox = await client.mailbox.get(accountId, mailboxId);
372
+ assertMailboxInAccount(mailbox, accountId, "act");
373
+
374
+ await client.mailboxQueue.deleteMailbox(mailboxId, accountId);
375
+ return { statusCode: 204 };
376
+ },
377
+ };
378
+
379
+ export const TrashOperations: Record<
380
+ TrashOperationIds,
381
+ OperationHandler<TrashOperationIds>
382
+ > = {
383
+ TrashOperations_emptyTrash: async (context, ...args: unknown[]) => {
384
+ const event = args[0] as APIGatewayProxyEvent;
385
+ const accountConfigId = getAccountConfigIdFromEvent(event);
386
+ const { accountId } = context.request.params as { accountId: string };
387
+
388
+ const client = await getClient();
389
+
390
+ const account = await client.account.get(accountId);
391
+ assertAccountOwnership(account, accountConfigId, "act");
392
+
393
+ const trashMailbox =
394
+ await client.mailboxSpecialUse.findTrashMailbox(accountId);
395
+
396
+ if (!trashMailbox) {
397
+ return { deletedCount: 0 };
398
+ }
399
+
400
+ // Get count of messages in trash before emptying
401
+ const messages = await client.message.listAllByMailbox(
402
+ trashMailbox.mailboxId,
403
+ );
404
+ const deletedCount = messages.length;
405
+
406
+ // MessageMoveService handles: Message status updates + SQS event
407
+ await client.messageMove.emptyTrash(accountConfigId, accountId);
408
+
409
+ return { deletedCount };
410
+ },
411
+ };
@@ -0,0 +1,190 @@
1
+ import { SendMessageCommand } from "@aws-sdk/client-sqs";
2
+ import type {
3
+ AccountExportRequestResponse,
4
+ CreateExportResponse,
5
+ DeleteAccountConfigResponse,
6
+ DeleteMeInput,
7
+ VipSuggestionsResponse,
8
+ } from "@remit/api-openapi-types";
9
+ import { BadRequestError, NotFoundError } from "@remit/data-ports/errors";
10
+ import { logger } from "@remit/logger-lambda";
11
+ import type { APIGatewayProxyEvent } from "aws-lambda";
12
+ import { env } from "expect-env";
13
+ import type { Context } from "openapi-backend";
14
+ import { getAccountConfigIdFromEvent, getSubFromEvent } from "../auth.js";
15
+ import { getClient } from "../service/dynamodb.js";
16
+ import { sqsClient } from "../service/sqs.js";
17
+ import type { MeOperationIds, OperationHandler } from "../types.js";
18
+ import { toVipSuggestionEntry } from "./vip-suggestions.js";
19
+
20
+ const DOWNLOAD_URL_TTL_SECONDS = 60 * 60;
21
+
22
+ export const MeOperations: Record<
23
+ MeOperationIds,
24
+ OperationHandler<MeOperationIds>
25
+ > = {
26
+ MeOperations_listVipSuggestions: async (
27
+ _context: Context,
28
+ ...args: unknown[]
29
+ ): Promise<VipSuggestionsResponse> => {
30
+ const event = args[0] as APIGatewayProxyEvent;
31
+ const accountConfigId = getAccountConfigIdFromEvent(event);
32
+
33
+ const { address } = await getClient();
34
+ const items = await address.listSuggestedVips({ accountConfigId });
35
+
36
+ return { suggestions: items.map(toVipSuggestionEntry) };
37
+ },
38
+ MeOperations_deleteMe: async (
39
+ _context: Context,
40
+ ...args: unknown[]
41
+ ): Promise<DeleteAccountConfigResponse> => {
42
+ const event = args[0] as APIGatewayProxyEvent;
43
+ const accountConfigId = getAccountConfigIdFromEvent(event);
44
+
45
+ const { confirmEmail } = JSON.parse(
46
+ event.body ?? "{}",
47
+ ) as Partial<DeleteMeInput>;
48
+ const callerEmail = event.requestContext?.authorizer?.claims?.email as
49
+ | string
50
+ | undefined;
51
+
52
+ if (typeof confirmEmail !== "string" || confirmEmail.trim() === "") {
53
+ throw new BadRequestError("confirmEmail is required");
54
+ }
55
+
56
+ if (!callerEmail) {
57
+ throw new BadRequestError("Account email claim missing from token");
58
+ }
59
+
60
+ if (confirmEmail.toLowerCase() !== callerEmail.toLowerCase()) {
61
+ throw new BadRequestError(
62
+ "confirmEmail does not match your account email",
63
+ );
64
+ }
65
+
66
+ const { accountConfig } = await getClient();
67
+
68
+ try {
69
+ await accountConfig.update(accountConfigId, {
70
+ state: "deleting",
71
+ deletedAt: Date.now(),
72
+ });
73
+ } catch (err: unknown) {
74
+ // If already in "deleting" state, treat as idempotent
75
+ if (
76
+ err instanceof Error &&
77
+ (err.name === "ConditionalCheckFailedException" ||
78
+ err.message.includes("ConditionalCheckFailed"))
79
+ ) {
80
+ return {
81
+ statusCode: 202,
82
+ message: "Account deletion already in progress",
83
+ };
84
+ }
85
+ throw err;
86
+ }
87
+
88
+ const queueUrl = env.SQS_QUEUE_URL_ACCOUNT_FANOUT;
89
+
90
+ await sqsClient.send(
91
+ new SendMessageCommand({
92
+ QueueUrl: queueUrl,
93
+ MessageBody: JSON.stringify({
94
+ type: "AccountDelete",
95
+ accountConfigId,
96
+ }),
97
+ }),
98
+ );
99
+
100
+ // biome-ignore lint/plugin/no-logger-info: account deletion is an audit-grade signal
101
+ logger.info({ accountConfigId }, "Account deletion initiated");
102
+
103
+ return {
104
+ statusCode: 202,
105
+ message: "Account deletion initiated",
106
+ };
107
+ },
108
+ MeOperations_createExport: async (
109
+ _context: Context,
110
+ ...args: unknown[]
111
+ ): Promise<CreateExportResponse> => {
112
+ const event = args[0] as APIGatewayProxyEvent;
113
+ const accountConfigId = getAccountConfigIdFromEvent(event);
114
+ const userId = getSubFromEvent(event);
115
+
116
+ if (!userId) {
117
+ throw new Error(
118
+ "Missing Cognito `sub`: cannot attribute an export request to a user",
119
+ );
120
+ }
121
+
122
+ const { accountExportRequest } = await getClient();
123
+ const exportRequest = await accountExportRequest.create({
124
+ accountConfigId,
125
+ userId,
126
+ state: "Pending",
127
+ });
128
+
129
+ await sqsClient.send(
130
+ new SendMessageCommand({
131
+ QueueUrl: env.SQS_QUEUE_URL_ACCOUNT_FANOUT,
132
+ MessageBody: JSON.stringify({
133
+ type: "AccountExport",
134
+ accountConfigId,
135
+ accountExportRequestId: exportRequest.accountExportRequestId,
136
+ }),
137
+ }),
138
+ );
139
+
140
+ // biome-ignore lint/plugin/no-logger-info: account export is an audit-grade signal
141
+ logger.info(
142
+ {
143
+ accountConfigId,
144
+ accountExportRequestId: exportRequest.accountExportRequestId,
145
+ },
146
+ "Account export initiated",
147
+ );
148
+
149
+ return {
150
+ statusCode: 202,
151
+ exportId: exportRequest.accountExportRequestId,
152
+ };
153
+ },
154
+ MeOperations_getExport: async (
155
+ context: Context,
156
+ ...args: unknown[]
157
+ ): Promise<AccountExportRequestResponse> => {
158
+ const event = args[0] as APIGatewayProxyEvent;
159
+ const accountConfigId = getAccountConfigIdFromEvent(event);
160
+ const exportId = context.request.params.exportId as string;
161
+
162
+ const { accountExportRequest, storage } = await getClient();
163
+ const exportRequest = await accountExportRequest.get(exportId);
164
+
165
+ if (exportRequest.accountConfigId !== accountConfigId) {
166
+ throw new NotFoundError(`Export not found: ${exportId}`);
167
+ }
168
+
169
+ const downloadUrl =
170
+ exportRequest.state === "Ready" && exportRequest.objectKey
171
+ ? await storage.getPresignedDownloadUrl(
172
+ exportRequest.objectKey,
173
+ DOWNLOAD_URL_TTL_SECONDS,
174
+ )
175
+ : undefined;
176
+
177
+ return {
178
+ accountExportRequestId: exportRequest.accountExportRequestId,
179
+ accountConfigId: exportRequest.accountConfigId,
180
+ userId: exportRequest.userId,
181
+ state: exportRequest.state,
182
+ createdAt: exportRequest.createdAt,
183
+ updatedAt: exportRequest.updatedAt,
184
+ objectKey: exportRequest.objectKey,
185
+ downloadUrl,
186
+ expiresAt: exportRequest.expiresAt,
187
+ errorMessage: exportRequest.errorMessage,
188
+ };
189
+ },
190
+ };