@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,83 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { chmodSync, mkdtempSync, rmSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import { afterEach, describe, it } from "node:test";
6
- import Database from "better-sqlite3";
7
- import { checkRelationalStore } from "./relational-health.js";
8
-
9
- describe("checkRelationalStore", () => {
10
- const ORIGINAL_DATA_BACKEND = process.env.DATA_BACKEND;
11
- const ORIGINAL_SQLITE_DB_PATH = process.env.SQLITE_DB_PATH;
12
- let dir: string | undefined;
13
-
14
- afterEach(() => {
15
- if (ORIGINAL_DATA_BACKEND === undefined) delete process.env.DATA_BACKEND;
16
- else process.env.DATA_BACKEND = ORIGINAL_DATA_BACKEND;
17
- if (ORIGINAL_SQLITE_DB_PATH === undefined) {
18
- delete process.env.SQLITE_DB_PATH;
19
- } else {
20
- process.env.SQLITE_DB_PATH = ORIGINAL_SQLITE_DB_PATH;
21
- }
22
- if (dir) {
23
- rmSync(dir, { recursive: true, force: true });
24
- dir = undefined;
25
- }
26
- });
27
-
28
- it("is true without touching disk on the AWS/DynamoDB path", async () => {
29
- delete process.env.DATA_BACKEND;
30
- delete process.env.SQLITE_DB_PATH;
31
- assert.equal(await checkRelationalStore(), true);
32
-
33
- process.env.DATA_BACKEND = "dynamodb";
34
- assert.equal(await checkRelationalStore(), true);
35
- });
36
-
37
- it("is true when the SQLite file is reachable", async () => {
38
- dir = mkdtempSync(join(tmpdir(), "remit-health-"));
39
- const dbPath = join(dir, "remit.db");
40
- new Database(dbPath).close();
41
-
42
- process.env.DATA_BACKEND = "sqlite";
43
- process.env.SQLITE_DB_PATH = dbPath;
44
-
45
- assert.equal(await checkRelationalStore(), true);
46
- });
47
-
48
- it("is false when SQLITE_DB_PATH points at a directory that does not exist", async () => {
49
- process.env.DATA_BACKEND = "sqlite";
50
- process.env.SQLITE_DB_PATH = "/nonexistent/remit.db";
51
-
52
- assert.equal(await checkRelationalStore(), false);
53
- });
54
-
55
- it("is false when the SQLite file exists but is unreadable", async () => {
56
- dir = mkdtempSync(join(tmpdir(), "remit-health-"));
57
- const dbPath = join(dir, "remit.db");
58
- new Database(dbPath).close();
59
- chmodSync(dbPath, 0o000);
60
-
61
- process.env.DATA_BACKEND = "sqlite";
62
- process.env.SQLITE_DB_PATH = dbPath;
63
-
64
- assert.equal(await checkRelationalStore(), false);
65
- });
66
-
67
- it("is false when the file is readable but no longer writable", async () => {
68
- dir = mkdtempSync(join(tmpdir(), "remit-health-"));
69
- const dbPath = join(dir, "remit.db");
70
- new Database(dbPath).close();
71
-
72
- process.env.DATA_BACKEND = "sqlite";
73
- process.env.SQLITE_DB_PATH = dbPath;
74
- assert.equal(await checkRelationalStore(), true);
75
-
76
- // Readable, not writable: the read-only SELECT ping still opens and
77
- // succeeds, so without `access(W_OK)` this reports healthy on a store the
78
- // backend can no longer write to. This is the case the writability check
79
- // exists to catch — dropping `access()` from pingSqlite fails this test.
80
- chmodSync(dbPath, 0o400);
81
- assert.equal(await checkRelationalStore(), false);
82
- });
83
- });
@@ -1,39 +0,0 @@
1
- import { access, constants } from "node:fs/promises";
2
- import { isSelfHostSqlBackend } from "../src/data-backend.js";
3
-
4
- // The SELECT ping opens a read-only connection, so it keeps succeeding on a
5
- // file that can still be read but no longer written — permissions revoked to
6
- // read-only, or the volume remounted read-only under the process. The backend
7
- // must write (better-auth persists sessions to this same file, RFC 036 D3), so
8
- // a readable-but-unwritable store has to report unhealthy. `access(R_OK|W_OK)`
9
- // checks both bits in one fresh syscall; the read-only query never covers
10
- // writability. The query after it still exercises the SQL engine itself.
11
- const pingSqlite = async (): Promise<void> => {
12
- const path = process.env.SQLITE_DB_PATH ?? "";
13
- await access(path, constants.R_OK | constants.W_OK);
14
-
15
- const { default: Database } = await import("better-sqlite3");
16
- const db = new Database(path, { readonly: true });
17
- try {
18
- db.prepare("SELECT 1").get();
19
- } finally {
20
- db.close();
21
- }
22
- };
23
-
24
- /**
25
- * A trivial read against the relational store — the dependency `/health` must
26
- * actually exercise (RFC 037 D5, R9) so a backend that
27
- * booted with an unusable database reports unhealthy instead of a bare 200. The
28
- * AWS/DynamoDB path has no relational store and is never checked.
29
- */
30
- export const checkRelationalStore = async (): Promise<boolean> => {
31
- if (!isSelfHostSqlBackend()) return true;
32
-
33
- try {
34
- await pingSqlite();
35
- return true;
36
- } catch {
37
- return false;
38
- }
39
- };
@@ -1,384 +0,0 @@
1
- import { readFile } from "node:fs/promises";
2
- import { resolve } from "node:path";
3
- import { logger } from "@remit/logger-lambda";
4
- import {
5
- metricsContentType,
6
- onScrape,
7
- renderMetrics,
8
- setAccountSyncAges,
9
- } from "@remit/logger-lambda/metrics";
10
- import {
11
- isStorageNotFoundError,
12
- UPLOAD_ROUTE_PREFIX,
13
- } from "@remit/storage-service";
14
- import type { APIGatewayProxyResult } from "aws-lambda";
15
- import { env } from "expect-env";
16
- import express, {
17
- type NextFunction,
18
- type Request,
19
- type Response,
20
- } from "express";
21
- import { handler, OpenAPISpec } from "../src/index.js";
22
- import { safeJsonParse } from "../src/json.js";
23
- import { getClient } from "../src/service/data-client.js";
24
- import { authorizeContentRequest } from "./content-auth.js";
25
- import { serveContent } from "./content-handler.js";
26
- import { resolveContentPath } from "./content-path.js";
27
- import { parseAllowedOrigins, resolveAllowOrigin } from "./cors.js";
28
- import { createLambdaContext, createLambdaEvent } from "./lambda-helpers.js";
29
- import { checkRelationalStore } from "./relational-health.js";
30
- import { collectAccountSyncAges } from "./sync-age.js";
31
- import { receiveUpload } from "./upload-handler.js";
32
-
33
- const app = express();
34
-
35
- // The self-host relational backend runs better-auth and the APISIX edge; the
36
- // AWS-local (DynamoDB) dev path runs neither. Everything gated on "not the
37
- // AWS-local path" keys off this.
38
- const isSelfHostBackend = process.env.DATA_BACKEND === "sqlite";
39
-
40
- // CORS is driven by CORS_ALLOWED_ORIGINS (comma-separated, or `*`). On the
41
- // self-host backend it is required config — refuse to start if unset, so the
42
- // deployed edge is never accidentally wide open by omission. On the AWS-local
43
- // dev path it defaults to `*` to keep the existing local flow working.
44
- const configuredCorsOrigins = parseAllowedOrigins(
45
- process.env.CORS_ALLOWED_ORIGINS,
46
- );
47
- if (isSelfHostBackend && configuredCorsOrigins.length === 0) {
48
- throw new Error(
49
- "Missing required env var CORS_ALLOWED_ORIGINS (comma-separated origins, or '*')",
50
- );
51
- }
52
- const corsAllowedOrigins =
53
- configuredCorsOrigins.length > 0 ? configuredCorsOrigins : ["*"];
54
-
55
- // better-auth owns identity on the self-host backends. Its handler must be
56
- // mounted BEFORE express.json() (better-auth reads the raw body itself; a prior
57
- // json parser leaves its fetch calls hanging). This mirrors the production edge
58
- // where the same better-auth service sits in front of the API.
59
- if (isSelfHostBackend) {
60
- // Synthetic OIDC discovery document for the APISIX edge tier. better-auth
61
- // serves a JWKS but not a discovery doc; APISIX's openid-connect plugin needs
62
- // one to locate the JWKS. Registered before the better-auth catch-all so it
63
- // is not shadowed. `issuer` matches the token `iss`, so it stays the public
64
- // BETTER_AUTH_URL. `jwks_uri` is different: APISIX fetches it directly, and
65
- // on a deployment where the public origin isn't reachable from inside the
66
- // container network (a tailnet/VPN address a bridge network can't route to
67
- // — the shape deploy/vps's compose stack runs in), deriving it from
68
- // BETTER_AUTH_URL would point APISIX at an address it cannot reach, even
69
- // though it just reached this very endpoint fine.
70
- //
71
- // BETTER_AUTH_JWKS_URL — the same in-network override
72
- // remit-auth-service's own verifier config already reads
73
- // (packages/auth-service/src/config.ts) and every env file sets to
74
- // the backend's in-network address — is authoritative here, not the
75
- // incoming request's Host header: an operator-controlled value instead
76
- // of one derived from client-supplied input. It only falls back to
77
- // echoing the request's own host when unset, matching prior behavior
78
- // for any environment that doesn't set it.
79
- app.get(
80
- "/api/auth/.well-known/openid-configuration",
81
- (req: Request, res: Response) => {
82
- const base =
83
- process.env.BETTER_AUTH_URL ??
84
- `http://localhost:${process.env.SERVER_PORT ?? "5436"}`;
85
- const jwksUri =
86
- process.env.BETTER_AUTH_JWKS_URL ??
87
- `${req.protocol}://${req.get("host")}/api/auth/jwks`;
88
- res.json({
89
- issuer: base,
90
- jwks_uri: jwksUri,
91
- authorization_endpoint: `${base}/api/auth/authorize`,
92
- token_endpoint: `${base}/api/auth/token`,
93
- response_types_supported: ["token"],
94
- subject_types_supported: ["public"],
95
- id_token_signing_alg_values_supported: ["RS256"],
96
- });
97
- },
98
- );
99
-
100
- const { createAuth, resolveAuthConfig, toNodeHandler, withRateLimitLogging } =
101
- await import("@remit/auth-service");
102
- const auth = await createAuth(resolveAuthConfig());
103
- app.all(/^\/api\/auth\//, toNodeHandler(withRateLimitLogging(auth)));
104
- }
105
-
106
- // Ahead of the body parsers, not after them: a body refused for its size never
107
- // reaches a route, and without these headers the browser reads that refusal as
108
- // a network failure instead of the reason it carries.
109
- app.use((req: Request, res: Response, next: NextFunction) => {
110
- const allowOrigin = resolveAllowOrigin(
111
- req.headers.origin,
112
- corsAllowedOrigins,
113
- );
114
- if (allowOrigin) {
115
- res.header("Access-Control-Allow-Origin", allowOrigin);
116
- if (allowOrigin !== "*") res.header("Vary", "Origin");
117
- }
118
- res.header(
119
- "Access-Control-Allow-Methods",
120
- "GET, POST, PUT, DELETE, OPTIONS, PATCH",
121
- );
122
- res.header(
123
- "Access-Control-Allow-Headers",
124
- "Origin, X-Requested-With, Content-Type, Accept, Authorization",
125
- );
126
-
127
- if (req.method === "OPTIONS") {
128
- res.sendStatus(200);
129
- } else {
130
- next();
131
- }
132
- });
133
-
134
- // The self-hosted upload receiver, the write-side twin of /content below. The
135
- // URL that reaches here was minted by the storage backend and carries its own
136
- // authority: an HMAC over the storage key, an expiry and the exact byte count.
137
- // No bearer token, for the same reason /content has none — the grant has to
138
- // travel in the URL itself. A hosted deployment presigns against block storage
139
- // instead and nothing arrives here at all.
140
- app.put(
141
- new RegExp(`^${UPLOAD_ROUTE_PREFIX}.+$`),
142
- async (req: Request, res: Response) => {
143
- const storageKey = req.path.slice(UPLOAD_ROUTE_PREFIX.length);
144
- const client = await getClient();
145
-
146
- const result = await receiveUpload(client.storage, {
147
- storageKey,
148
- exp: typeof req.query.exp === "string" ? req.query.exp : undefined,
149
- max: typeof req.query.max === "string" ? req.query.max : undefined,
150
- sig: typeof req.query.sig === "string" ? req.query.sig : undefined,
151
- body: req,
152
- nowSeconds: Math.floor(Date.now() / 1000),
153
- secret: process.env.BETTER_AUTH_SECRET,
154
- findLiveReservation: (accountConfigId, outboxAttachmentId) =>
155
- client.outboxAttachment.hasLiveReservation(
156
- accountConfigId,
157
- outboxAttachmentId,
158
- ),
159
- });
160
-
161
- res.setHeader("x-remit-upload-reason", result.reason);
162
- res.status(result.status);
163
- res.send(result.status === 204 ? undefined : result.reason);
164
- },
165
- );
166
-
167
- app.use(express.json({ limit: "10mb" }));
168
- app.use(express.urlencoded({ extended: true }));
169
-
170
- app.get("/.well-known/appspecific/com.chrome.devtools.json", () => ({}));
171
-
172
- app.get("/health", async (_req: Request, res: Response) => {
173
- const relationalStoreReachable = await checkRelationalStore();
174
- if (!relationalStoreReachable) {
175
- res.status(503).json({ status: "unhealthy" });
176
- return;
177
- }
178
- res.json({
179
- status: "ok",
180
- timestamp: new Date().toISOString(),
181
- service: "remit-backend-local",
182
- });
183
- });
184
-
185
- // The scrape endpoint (standalone-observability D2), on the port this server
186
- // already serves on and never routed through Caddy — deploy/vps/caddy/routes.caddy
187
- // proxies /api/*, /content/* and /health, and everything else goes to the static
188
- // web server, so there is no path from the public origin to this route.
189
- //
190
- // The per-account sync age is a database read, so it is collected when a scrape
191
- // arrives rather than tracked as syncs complete. A read that fails fails the
192
- // scrape: a signal that could not be evaluated must not render as a healthy
193
- // number. Only the self-host backends have a store to read here — the
194
- // AWS-local dev path composes its client from outside this module.
195
- if (isSelfHostBackend) {
196
- onScrape(async () => {
197
- const client = await getClient();
198
- setAccountSyncAges(await collectAccountSyncAges(client, Date.now()));
199
- });
200
- }
201
-
202
- app.get("/metrics", async (_req: Request, res: Response) => {
203
- const body = await renderMetrics().catch((error: unknown) => {
204
- logger.error({ error: String(error) }, "Metrics collection failed");
205
- return null;
206
- });
207
- if (body === null) {
208
- res.status(500).type("text/plain").send("metrics collection failed\n");
209
- return;
210
- }
211
- res.setHeader("content-type", metricsContentType).send(body);
212
- });
213
-
214
- // Swagger UI exposes the full API schema, so it must never be on the public
215
- // surface. On the self-host backends this server is the deployed backend
216
- // container; gate the docs to the AWS-local dev path only. The generated
217
- // OpenAPI document is still consumed programmatically by the APISIX edge via its
218
- // own mounted paths — this only removes the browsable UI from the deployed
219
- // surface.
220
- //
221
- // Dynamic import, not a static one: swagger-ui-express uses `__dirname` to
222
- // locate its bundled assets on disk, which esbuild only shims (not
223
- // eliminates) for an unconditional top-level import — that shim plus this
224
- // file's own top-level `await import("@remit/auth-service")` above
225
- // makes the bundle's module format ambiguous to Node
226
- // (`ERR_AMBIGUOUS_MODULE_SYNTAX`), crashing every backend container start,
227
- // including the ones that never take this branch. A dynamic import here
228
- // keeps swagger-ui-express (and its `__dirname` shim) out of module scope
229
- // entirely unless this is the AWS-local dev path.
230
- if (!isSelfHostBackend) {
231
- const swaggerUi = await import("swagger-ui-express");
232
- const localSpec = { ...OpenAPISpec };
233
- if (localSpec.servers) {
234
- localSpec.servers[0].url = "/";
235
- }
236
- app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(localSpec));
237
- }
238
-
239
- // Local stand-in for the CloudFront `/content/*` behavior. In production the
240
- // Lambda@Edge JWT verifier guards these requests and CloudFront serves them
241
- // from the storage S3 bucket via OAC. Locally we stream the bytes from the
242
- // filesystem-backed storage location keyed by URL path. No JWT check — this
243
- // server only runs against an isolated dev/e2e DynamoDB + storage tree.
244
- //
245
- // `LOCAL_CONTENT_STORAGE_BASE` lets the e2e/smoke fixture point this server at
246
- // the same absolute filesystem root it seeded under (repo-relative), since
247
- // the fixture writes from the repo root while the dev-server's cwd is the
248
- // playwright workspace.
249
- const STORAGE_LOCAL_PATH = process.env.STORAGE_LOCAL_PATH ?? ".remit/storage";
250
- const STORAGE_BASE = resolve(
251
- process.env.LOCAL_CONTENT_STORAGE_BASE ?? process.cwd(),
252
- STORAGE_LOCAL_PATH,
253
- );
254
-
255
- app.get(/^\/content\/.+$/, async (req: Request, res: Response) => {
256
- const storageKey = req.path.replace(/^\/content\//, "");
257
-
258
- // On the self-host stack this route is the deployed content-delivery surface;
259
- // require a valid signed URL (HMAC + expiry, scoped to the owning account)
260
- // before touching storage. Bearer auth can't ride on an `<img src>` content
261
- // load, so the signature carried in the query string is the authorization.
262
- const auth = authorizeContentRequest({
263
- dataBackend: process.env.DATA_BACKEND,
264
- secret: process.env.BETTER_AUTH_SECRET,
265
- relativePath: storageKey,
266
- exp: typeof req.query.exp === "string" ? req.query.exp : undefined,
267
- sig: typeof req.query.sig === "string" ? req.query.sig : undefined,
268
- nowSeconds: Math.floor(Date.now() / 1000),
269
- });
270
- if (!auth.authorized) {
271
- res.status(auth.status).setHeader("x-remit-403-reason", auth.reason);
272
- res.send(auth.reason);
273
- return;
274
- }
275
-
276
- const fullPath = resolveContentPath(STORAGE_BASE, storageKey);
277
- if (fullPath === null) {
278
- res.status(400).send("invalid path");
279
- return;
280
- }
281
-
282
- const client = await getClient();
283
- const result = await serveContent(
284
- {
285
- // ENOENT means the body isn't synced yet (→ 202); any other read error
286
- // throws so express 500s it — never masked as a missing body.
287
- readObject: async (path) =>
288
- readFile(path).catch((error: unknown) => {
289
- if (isStorageNotFoundError(error)) return null;
290
- throw error;
291
- }),
292
- lookupMessage: async (messageId) => {
293
- // A gone message row means nothing to re-arm — still answer 202.
294
- const message = await client.message.get(messageId).catch(() => null);
295
- return message
296
- ? { mailboxId: message.mailboxId, uid: message.uid }
297
- : null;
298
- },
299
- requestBodySync: async (input) => {
300
- await client.bodySyncQueue?.requestBodySync(input);
301
- },
302
- },
303
- { fullPath, storageKey },
304
- );
305
-
306
- res.status(result.status);
307
- for (const [name, value] of Object.entries(result.headers)) {
308
- res.setHeader(name, value);
309
- }
310
- res.send(result.body);
311
- });
312
-
313
- app.all(/(.*)/, async (req: Request, res: Response) => {
314
- const event = createLambdaEvent(req);
315
- const context = createLambdaContext();
316
-
317
- const result: APIGatewayProxyResult = await handler(event, context);
318
- const headers = (result.headers ?? {}) as Record<string, string>;
319
-
320
- let body: unknown = result.body;
321
-
322
- if (
323
- typeof body === "string" &&
324
- headers["Content-Type"]?.includes("application/json")
325
- ) {
326
- const parsed = await safeJsonParse<unknown>(body).catch(() => undefined);
327
- if (parsed === undefined) {
328
- logger.error("Failed to parse JSON body");
329
- } else if (
330
- parsed &&
331
- typeof parsed === "object" &&
332
- "statusCode" in parsed &&
333
- "body" in parsed
334
- ) {
335
- const inner = (parsed as { body: unknown }).body;
336
- body =
337
- typeof inner === "string"
338
- ? await safeJsonParse(inner).catch(() => inner)
339
- : inner;
340
- } else {
341
- body = parsed;
342
- }
343
- }
344
-
345
- res
346
- .setHeaders(new Map(Object.entries(headers)))
347
- .status(result.statusCode)
348
- .send(body);
349
- });
350
-
351
- const port = env.SERVER_PORT;
352
-
353
- // This file is the backend image's entrypoint, so its startup output is the
354
- // first thing a log collector reads from the container. It goes through the
355
- // logger for the same reason every other line does: one JSON object per line is
356
- // the contract in deploy/vps/README.md, and a banner printed alongside it is a
357
- // line the pipeline cannot parse.
358
- //
359
- // This is also the everyday `npm run dev` server, so the addresses stay whole
360
- // and clickable — `url`, not a port a developer has to assemble one themselves.
361
- // Exported so a test that drives this exact app can shut the listener down
362
- // afterwards; nothing else has any business holding it.
363
- export const listener = app.listen(Number(port), "0.0.0.0", () => {
364
- // biome-ignore lint/plugin/no-logger-info: the configuration a container came up on is an audit-grade signal
365
- logger.info(
366
- {
367
- port: Number(port),
368
- url: `http://localhost:${port}`,
369
- dataBackend: process.env.DATA_BACKEND,
370
- nodeEnv: env.NODE_ENV,
371
- ...(isSelfHostBackend
372
- ? {}
373
- : { apiDocsUrl: `http://localhost:${port}/api-docs` }),
374
- },
375
- "Backend listening",
376
- );
377
-
378
- process.send?.("ready");
379
- });
380
-
381
- // Outlasts the idle timeout of every proxy that reuses connections to it.
382
- listener.keepAliveTimeout = 65_000;
383
-
384
- export default app;
@@ -1,91 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import type { AccountItem, MailboxItem } from "@remit/data-ports";
4
- import { collectAccountSyncAges, type SyncAgeSource } from "./sync-age.js";
5
-
6
- const NOW = 1_700_000_000_000;
7
-
8
- const account = (overrides: Partial<AccountItem>): AccountItem =>
9
- ({
10
- accountId: "acct-1",
11
- createdAt: NOW - 600_000,
12
- lastSyncAt: NOW,
13
- ...overrides,
14
- }) as AccountItem;
15
-
16
- const mailbox = (lastMessageSyncAt: number): MailboxItem =>
17
- ({ mailboxId: `mbx-${lastMessageSyncAt}`, lastMessageSyncAt }) as MailboxItem;
18
-
19
- const source = (
20
- accounts: AccountItem[],
21
- mailboxes: Record<string, MailboxItem[]>,
22
- ): SyncAgeSource => ({
23
- account: { listAll: async () => accounts },
24
- mailbox: {
25
- listAllByAccount: async (accountId: string) => mailboxes[accountId] ?? [],
26
- },
27
- });
28
-
29
- describe("collectAccountSyncAges", () => {
30
- it("measures from the newest mailbox message-sync stamp", async () => {
31
- const ages = await collectAccountSyncAges(
32
- source([account({})], {
33
- "acct-1": [mailbox(NOW - 300_000), mailbox(NOW - 60_000)],
34
- }),
35
- NOW,
36
- );
37
- assert.deepEqual(ages, [{ accountId: "acct-1", ageSeconds: 60 }]);
38
- });
39
-
40
- it("ignores account.lastSyncAt, which is stamped before the message fan-out", async () => {
41
- // lastSyncAt is fresh and every message handler has been failing for an
42
- // hour. The exported age must report the hour.
43
- const ages = await collectAccountSyncAges(
44
- source([account({ lastSyncAt: NOW })], {
45
- "acct-1": [mailbox(NOW - 3_600_000)],
46
- }),
47
- NOW,
48
- );
49
- assert.deepEqual(ages, [{ accountId: "acct-1", ageSeconds: 3600 }]);
50
- });
51
-
52
- it("treats an unstamped mailbox as never synced", async () => {
53
- const ages = await collectAccountSyncAges(
54
- source([account({ createdAt: NOW - 900_000 })], {
55
- "acct-1": [mailbox(0)],
56
- }),
57
- NOW,
58
- );
59
- assert.deepEqual(ages, [{ accountId: "acct-1", ageSeconds: 900 }]);
60
- });
61
-
62
- it("reports an account with no mailboxes at all rather than omitting it", async () => {
63
- const ages = await collectAccountSyncAges(
64
- source([account({ createdAt: NOW - 120_000 })], {}),
65
- NOW,
66
- );
67
- assert.deepEqual(ages, [{ accountId: "acct-1", ageSeconds: 120 }]);
68
- });
69
-
70
- it("skips a deleted account", async () => {
71
- const ages = await collectAccountSyncAges(
72
- source(
73
- [
74
- account({ accountId: "gone", deletedAt: NOW - 1000 }),
75
- account({ accountId: "live" }),
76
- ],
77
- { live: [mailbox(NOW - 30_000)] },
78
- ),
79
- NOW,
80
- );
81
- assert.deepEqual(ages, [{ accountId: "live", ageSeconds: 30 }]);
82
- });
83
-
84
- it("never reports a negative age when a stamp is ahead of the clock", async () => {
85
- const ages = await collectAccountSyncAges(
86
- source([account({})], { "acct-1": [mailbox(NOW + 5_000)] }),
87
- NOW,
88
- );
89
- assert.deepEqual(ages, [{ accountId: "acct-1", ageSeconds: 0 }]);
90
- });
91
- });
@@ -1,55 +0,0 @@
1
- import type { IAccountRepository, IMailboxRepository } from "@remit/data-ports";
2
-
3
- export interface SyncAgeSource {
4
- readonly account: Pick<IAccountRepository, "listAll">;
5
- readonly mailbox: Pick<IMailboxRepository, "listAllByAccount">;
6
- }
7
-
8
- export interface AccountSyncAge {
9
- readonly accountId: string;
10
- readonly ageSeconds: number;
11
- }
12
-
13
- /**
14
- * Seconds since each account last completed a message-sync round
15
- * (standalone-observability D3).
16
- *
17
- * Measured from `mailbox.lastMessageSyncAt`, not `account.lastSyncAt`.
18
- * `account.lastSyncAt` is stamped after the mailbox *list* sync and before the
19
- * per-mailbox fan-out that fetches messages, so a deployment whose message
20
- * handlers all throw keeps it fresh forever while no mail arrives.
21
- * `lastMessageSyncAt` is stamped at the end of a message-sync round, after the
22
- * fetch and the writes.
23
- *
24
- * The value is the age of the newest such stamp across the account's mailboxes:
25
- * seconds since this account last completed a round trip that would have found
26
- * new mail if there were any. An account with no stamped mailbox has never
27
- * completed one, and reports the age of the account row instead of being
28
- * omitted — "never synced" is the condition most worth seeing, not a gap in the
29
- * series.
30
- *
31
- * Labelled by account id, never by address: a scraped label travels wherever
32
- * the scrape goes.
33
- */
34
- export const collectAccountSyncAges = async (
35
- source: SyncAgeSource,
36
- now: number,
37
- ): Promise<AccountSyncAge[]> => {
38
- const accounts = await source.account.listAll();
39
- const ages: AccountSyncAge[] = [];
40
- for (const account of accounts) {
41
- if (account.deletedAt) continue;
42
- const mailboxes = await source.mailbox.listAllByAccount(account.accountId);
43
- const stamps = mailboxes
44
- .map((mailbox) => mailbox.lastMessageSyncAt)
45
- .filter(
46
- (stamp): stamp is number => typeof stamp === "number" && stamp > 0,
47
- );
48
- const newest = stamps.length > 0 ? Math.max(...stamps) : account.createdAt;
49
- ages.push({
50
- accountId: account.accountId,
51
- ageSeconds: Math.max(0, Math.round((now - newest) / 1000)),
52
- });
53
- }
54
- return ages;
55
- };