@remit/backend 0.0.143 → 0.0.144

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 (269) 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 +7 -0
  4. package/dist/src/api.js +144 -0
  5. package/dist/src/auth.d.ts +30 -0
  6. package/dist/src/auth.js +73 -0
  7. package/dist/src/config/msoauth.d.ts +30 -0
  8. package/dist/src/config/msoauth.js +30 -0
  9. package/{src/data-backend.ts → dist/src/data-backend.d.ts} +2 -4
  10. package/dist/src/data-backend.js +12 -0
  11. package/{src/derive/autoMoved.ts → dist/src/derive/autoMoved.d.ts} +1 -29
  12. package/dist/src/derive/autoMoved.js +49 -0
  13. package/dist/src/derive/contentSignature.d.ts +41 -0
  14. package/{src/derive/contentSignature.ts → dist/src/derive/contentSignature.js} +27 -72
  15. package/dist/src/derive/contentUrl.d.ts +46 -0
  16. package/dist/src/derive/contentUrl.js +46 -0
  17. package/{src/derive/deriveMuted.ts → dist/src/derive/deriveMuted.d.ts} +1 -3
  18. package/dist/src/derive/deriveMuted.js +7 -0
  19. package/dist/src/derive/enrichThreadRows.d.ts +118 -0
  20. package/dist/src/derive/enrichThreadRows.js +151 -0
  21. package/dist/src/derive/filterThreadCriteria.d.ts +23 -0
  22. package/dist/src/derive/filterThreadCriteria.js +24 -0
  23. package/{src/derive/pendingMoveCounts.ts → dist/src/derive/pendingMoveCounts.d.ts} +5 -53
  24. package/dist/src/derive/pendingMoveCounts.js +53 -0
  25. package/{src/derive/senderTrust.ts → dist/src/derive/senderTrust.d.ts} +1 -9
  26. package/dist/src/derive/senderTrust.js +19 -0
  27. package/dist/src/error.d.ts +21 -0
  28. package/dist/src/error.js +85 -0
  29. package/dist/src/handlers/account-guards.d.ts +36 -0
  30. package/dist/src/handlers/account-guards.js +107 -0
  31. package/dist/src/handlers/account-oauth.d.ts +14 -0
  32. package/dist/src/handlers/account-oauth.js +387 -0
  33. package/dist/src/handlers/account-overrides.d.ts +53 -0
  34. package/dist/src/handlers/account-overrides.js +233 -0
  35. package/dist/src/handlers/account-signature.d.ts +28 -0
  36. package/dist/src/handlers/account-signature.js +85 -0
  37. package/dist/src/handlers/account.d.ts +6 -0
  38. package/dist/src/handlers/account.js +350 -0
  39. package/dist/src/handlers/address.d.ts +35 -0
  40. package/dist/src/handlers/address.js +141 -0
  41. package/dist/src/handlers/calendar-event.d.ts +70 -0
  42. package/dist/src/handlers/calendar-event.js +425 -0
  43. package/dist/src/handlers/calendar-feed.d.ts +47 -0
  44. package/dist/src/handlers/calendar-feed.js +192 -0
  45. package/dist/src/handlers/calendar-suggestion.d.ts +47 -0
  46. package/dist/src/handlers/calendar-suggestion.js +212 -0
  47. package/dist/src/handlers/calendar.d.ts +116 -0
  48. package/dist/src/handlers/calendar.js +330 -0
  49. package/dist/src/handlers/config.d.ts +43 -0
  50. package/dist/src/handlers/config.js +176 -0
  51. package/dist/src/handlers/ensure-account-config.d.ts +9 -0
  52. package/dist/src/handlers/ensure-account-config.js +27 -0
  53. package/dist/src/handlers/filter.d.ts +3 -0
  54. package/dist/src/handlers/filter.js +103 -0
  55. package/dist/src/handlers/folder-role-appointments.d.ts +68 -0
  56. package/dist/src/handlers/folder-role-appointments.js +168 -0
  57. package/dist/src/handlers/folder-role-labels.d.ts +26 -0
  58. package/dist/src/handlers/folder-role-labels.js +40 -0
  59. package/dist/src/handlers/folder-role.d.ts +9 -0
  60. package/dist/src/handlers/folder-role.js +40 -0
  61. package/dist/src/handlers/index.d.ts +2 -0
  62. package/dist/src/handlers/index.js +60 -0
  63. package/dist/src/handlers/label.d.ts +43 -0
  64. package/dist/src/handlers/label.js +114 -0
  65. package/dist/src/handlers/mailbox-bindings.d.ts +41 -0
  66. package/dist/src/handlers/mailbox-bindings.js +61 -0
  67. package/dist/src/handlers/mailbox.d.ts +23 -0
  68. package/dist/src/handlers/mailbox.js +219 -0
  69. package/dist/src/handlers/me.d.ts +2 -0
  70. package/dist/src/handlers/me.js +138 -0
  71. package/dist/src/handlers/message.d.ts +84 -0
  72. package/dist/src/handlers/message.js +607 -0
  73. package/dist/src/handlers/organize.d.ts +10 -0
  74. package/dist/src/handlers/organize.js +112 -0
  75. package/dist/src/handlers/outbox-attachment.d.ts +23 -0
  76. package/dist/src/handlers/outbox-attachment.js +81 -0
  77. package/dist/src/handlers/outbox.d.ts +4 -0
  78. package/dist/src/handlers/outbox.js +144 -0
  79. package/dist/src/handlers/sync-progress.d.ts +46 -0
  80. package/dist/src/handlers/sync-progress.js +63 -0
  81. package/dist/src/handlers/sync.d.ts +28 -0
  82. package/dist/src/handlers/sync.js +82 -0
  83. package/dist/src/handlers/system-update.d.ts +2 -0
  84. package/dist/src/handlers/system-update.js +172 -0
  85. package/dist/src/handlers/thread.d.ts +148 -0
  86. package/dist/src/handlers/thread.js +233 -0
  87. package/dist/src/handlers/unified-threads.d.ts +247 -0
  88. package/dist/src/handlers/unified-threads.js +409 -0
  89. package/{src/handlers/vip-suggestions.ts → dist/src/handlers/vip-suggestions.d.ts} +1 -13
  90. package/dist/src/handlers/vip-suggestions.js +16 -0
  91. package/dist/src/index.d.ts +2 -0
  92. package/dist/src/index.js +37 -0
  93. package/{src/json.ts → dist/src/json.d.ts} +1 -4
  94. package/dist/src/json.js +10 -0
  95. package/dist/src/jwt-auth.d.ts +18 -0
  96. package/dist/src/jwt-auth.js +97 -0
  97. package/dist/src/request-context.d.ts +9 -0
  98. package/dist/src/request-context.js +22 -0
  99. package/dist/src/request.d.ts +3 -0
  100. package/dist/src/request.js +7 -0
  101. package/dist/src/response.d.ts +27 -0
  102. package/dist/src/response.js +87 -0
  103. package/dist/src/service/account-deletion.d.ts +22 -0
  104. package/dist/src/service/account-deletion.js +39 -0
  105. package/dist/src/service/compose-sqlite.d.ts +2 -0
  106. package/dist/src/service/compose-sqlite.js +11 -0
  107. package/dist/src/service/create-remit-client.d.ts +95 -0
  108. package/dist/src/service/create-remit-client.js +207 -0
  109. package/dist/src/service/data-client.d.ts +12 -0
  110. package/dist/src/service/data-client.js +44 -0
  111. package/dist/src/service/export-identity.d.ts +3 -0
  112. package/dist/src/service/export-identity.js +35 -0
  113. package/dist/src/service/fire-and-forget.d.ts +54 -0
  114. package/dist/src/service/fire-and-forget.js +38 -0
  115. package/dist/src/service/organize.d.ts +184 -0
  116. package/dist/src/service/organize.js +384 -0
  117. package/dist/src/service/refusal.d.ts +4 -0
  118. package/dist/src/service/refusal.js +20 -0
  119. package/dist/src/service/sender-category.d.ts +51 -0
  120. package/{src/service/sender-category.ts → dist/src/service/sender-category.js} +40 -81
  121. package/dist/src/service/sqs.d.ts +1 -0
  122. package/{src/service/sqs.ts → dist/src/service/sqs.js} +0 -1
  123. package/dist/src/service/trigger-sync.d.ts +51 -0
  124. package/dist/src/service/trigger-sync.js +39 -0
  125. package/dist/src/types.d.ts +49 -0
  126. package/dist/src/types.js +1 -0
  127. package/package.json +113 -98
  128. package/.env.test +0 -4
  129. package/dev-server/content-auth.test.ts +0 -113
  130. package/dev-server/content-auth.ts +0 -54
  131. package/dev-server/content-handler.test.ts +0 -105
  132. package/dev-server/content-handler.ts +0 -79
  133. package/dev-server/content-path.test.ts +0 -37
  134. package/dev-server/content-path.ts +0 -27
  135. package/dev-server/cors.test.ts +0 -48
  136. package/dev-server/cors.ts +0 -25
  137. package/dev-server/keep-alive.test.ts +0 -24
  138. package/dev-server/lambda-helpers.ts +0 -69
  139. package/dev-server/log-output.test.ts +0 -173
  140. package/dev-server/oauth-callback-route.test.ts +0 -66
  141. package/dev-server/relational-health.test.ts +0 -83
  142. package/dev-server/relational-health.ts +0 -39
  143. package/dev-server/server.ts +0 -384
  144. package/dev-server/sync-age.test.ts +0 -91
  145. package/dev-server/sync-age.ts +0 -55
  146. package/dev-server/upload-handler.ts +0 -132
  147. package/dev-server/upload-route.test.ts +0 -196
  148. package/scripts/backfill-classification.ts +0 -91
  149. package/scripts/backfill-list-id.ts +0 -83
  150. package/scripts/config-save.ts +0 -128
  151. package/src/auth.test.ts +0 -12
  152. package/src/auth.ts +0 -92
  153. package/src/config/msoauth.test.ts +0 -48
  154. package/src/config/msoauth.ts +0 -48
  155. package/src/data-backend.test.ts +0 -23
  156. package/src/derive/autoMoved.test.ts +0 -104
  157. package/src/derive/contentSignature.test.ts +0 -161
  158. package/src/derive/contentUrl.test.ts +0 -172
  159. package/src/derive/contentUrl.ts +0 -86
  160. package/src/derive/deriveMuted.test.ts +0 -34
  161. package/src/derive/enrichThreadRows.test.ts +0 -360
  162. package/src/derive/enrichThreadRows.ts +0 -234
  163. package/src/derive/filterThreadCriteria.test.ts +0 -126
  164. package/src/derive/filterThreadCriteria.ts +0 -51
  165. package/src/derive/senderTrust.test.ts +0 -67
  166. package/src/error.test.ts +0 -201
  167. package/src/error.ts +0 -123
  168. package/src/handlers/account-guards.test.ts +0 -67
  169. package/src/handlers/account-guards.ts +0 -169
  170. package/src/handlers/account-oauth-callback.test.ts +0 -155
  171. package/src/handlers/account-oauth-consent.test.ts +0 -336
  172. package/src/handlers/account-oauth-start.test.ts +0 -191
  173. package/src/handlers/account-oauth.test.ts +0 -439
  174. package/src/handlers/account-oauth.ts +0 -635
  175. package/src/handlers/account-overrides.test.ts +0 -88
  176. package/src/handlers/account-overrides.ts +0 -323
  177. package/src/handlers/account-ownership.ts +0 -25
  178. package/src/handlers/account-signature.ts +0 -117
  179. package/src/handlers/account-synced-services.test.ts +0 -245
  180. package/src/handlers/account.ts +0 -552
  181. package/src/handlers/address.test.ts +0 -381
  182. package/src/handlers/address.ts +0 -200
  183. package/src/handlers/calendar-event.test.ts +0 -685
  184. package/src/handlers/calendar-event.ts +0 -738
  185. package/src/handlers/calendar-feed-validators.test.ts +0 -228
  186. package/src/handlers/calendar-feed.test.ts +0 -439
  187. package/src/handlers/calendar-feed.ts +0 -291
  188. package/src/handlers/calendar-sqlite-fixture.ts +0 -36
  189. package/src/handlers/calendar-suggestion.test.ts +0 -1269
  190. package/src/handlers/calendar-suggestion.ts +0 -425
  191. package/src/handlers/calendar.test.ts +0 -1080
  192. package/src/handlers/calendar.ts +0 -517
  193. package/src/handlers/config.test.ts +0 -245
  194. package/src/handlers/config.ts +0 -332
  195. package/src/handlers/ensure-account-config.ts +0 -30
  196. package/src/handlers/filter.test.ts +0 -644
  197. package/src/handlers/filter.ts +0 -518
  198. package/src/handlers/folder-role-appointments.test.ts +0 -438
  199. package/src/handlers/folder-role-appointments.ts +0 -249
  200. package/src/handlers/folder-role-labels.ts +0 -63
  201. package/src/handlers/folder-role.test.ts +0 -209
  202. package/src/handlers/folder-role.ts +0 -80
  203. package/src/handlers/index.ts +0 -83
  204. package/src/handlers/label.test.ts +0 -112
  205. package/src/handlers/label.ts +0 -210
  206. package/src/handlers/mailbox-bindings.ts +0 -111
  207. package/src/handlers/mailbox-rename-refusal.test.ts +0 -247
  208. package/src/handlers/mailbox-settled-target.test.ts +0 -251
  209. package/src/handlers/mailbox.test.ts +0 -280
  210. package/src/handlers/mailbox.ts +0 -581
  211. package/src/handlers/me.test.ts +0 -81
  212. package/src/handlers/me.ts +0 -203
  213. package/src/handlers/message-read-backfill.test.ts +0 -103
  214. package/src/handlers/message.test.ts +0 -131
  215. package/src/handlers/message.ts +0 -1131
  216. package/src/handlers/organize.test.ts +0 -277
  217. package/src/handlers/organize.ts +0 -197
  218. package/src/handlers/outbox-attachment.test.ts +0 -765
  219. package/src/handlers/outbox-attachment.ts +0 -126
  220. package/src/handlers/outbox-list.test.ts +0 -179
  221. package/src/handlers/outbox.test.ts +0 -772
  222. package/src/handlers/outbox.ts +0 -258
  223. package/src/handlers/search.test.ts +0 -161
  224. package/src/handlers/search.ts +0 -105
  225. package/src/handlers/sync-progress.test.ts +0 -205
  226. package/src/handlers/sync-progress.ts +0 -99
  227. package/src/handlers/sync.test.ts +0 -168
  228. package/src/handlers/sync.ts +0 -118
  229. package/src/handlers/system-update.test.ts +0 -497
  230. package/src/handlers/system-update.ts +0 -230
  231. package/src/handlers/thread.test.ts +0 -396
  232. package/src/handlers/thread.ts +0 -427
  233. package/src/handlers/unified-threads-mail-sources.sqlite.test.ts +0 -197
  234. package/src/handlers/unified-threads.test.ts +0 -785
  235. package/src/handlers/unified-threads.ts +0 -669
  236. package/src/index.test.ts +0 -83
  237. package/src/index.ts +0 -228
  238. package/src/jwt-auth.test.ts +0 -152
  239. package/src/jwt-auth.ts +0 -132
  240. package/src/log-redaction.test.ts +0 -144
  241. package/src/openapi-spec.test.ts +0 -118
  242. package/src/request-context.test.ts +0 -61
  243. package/src/request-context.ts +0 -33
  244. package/src/request-pipeline.test.ts +0 -88
  245. package/src/request.ts +0 -10
  246. package/src/response.test.ts +0 -130
  247. package/src/response.ts +0 -144
  248. package/src/service/compose-sqlite.ts +0 -98
  249. package/src/service/config-import.ts +0 -39
  250. package/src/service/create-remit-client.ts +0 -522
  251. package/src/service/data-client.test.ts +0 -97
  252. package/src/service/data-client.ts +0 -58
  253. package/src/service/export-identity.ts +0 -39
  254. package/src/service/filter.ts +0 -40
  255. package/src/service/fire-and-forget.test.ts +0 -126
  256. package/src/service/fire-and-forget.ts +0 -79
  257. package/src/service/localhost-env-config.test.ts +0 -36
  258. package/src/service/organize-queue.ts +0 -98
  259. package/src/service/organize.test.ts +0 -1451
  260. package/src/service/organize.ts +0 -1025
  261. package/src/service/read-path-filter-config.test.ts +0 -243
  262. package/src/service/semantic-capability.test.ts +0 -78
  263. package/src/service/semantic-capability.ts +0 -95
  264. package/src/service/sender-category.test.ts +0 -235
  265. package/src/service/trigger-sync.test.ts +0 -205
  266. package/src/service/trigger-sync.ts +0 -100
  267. package/src/stdout-capture-fixture.ts +0 -40
  268. package/src/types.ts +0 -271
  269. package/tsconfig.json +0 -7
@@ -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
- };
@@ -1,132 +0,0 @@
1
- import type { Readable } from "node:stream";
2
- import {
3
- authorizeUploadRequest,
4
- parseOutboxAttachmentKey,
5
- type StorageService,
6
- } from "@remit/storage-service";
7
-
8
- /**
9
- * The self-hosted receiver for an attachment upload.
10
- *
11
- * A hosted deployment mints a presigned PUT and none of this runs: the browser
12
- * writes to block storage and the size is bound by the storage service itself.
13
- * Where there is no storage service to presign against, this route is the
14
- * equivalent, and it has to bind the same things — one attachment, one size,
15
- * for a while — on its own.
16
- */
17
-
18
- export type CollectedBody =
19
- | { readonly outcome: "Collected"; readonly content: Buffer }
20
- | { readonly outcome: "TooLarge" };
21
-
22
- /**
23
- * Read a request body, refusing it the moment it passes what was reserved.
24
- *
25
- * The count is checked per chunk and the stream destroyed on the one that
26
- * crosses the line, so a client that reserved a megabyte and started sending a
27
- * hundred is cut off after the first megabyte rather than after all of it.
28
- */
29
- export const collectLimitedBody = async (
30
- stream: Readable,
31
- maxBytes: number,
32
- ): Promise<CollectedBody> => {
33
- const chunks: Buffer[] = [];
34
- let received = 0;
35
-
36
- for await (const chunk of stream) {
37
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
38
- received += buffer.length;
39
- if (received > maxBytes) {
40
- // Stop reading, but leave the socket alone: destroying it here races the
41
- // 413 the caller is about to write, and the client sees a connection
42
- // reset instead of the reason it was refused.
43
- stream.pause();
44
- return { outcome: "TooLarge" };
45
- }
46
- chunks.push(buffer);
47
- }
48
-
49
- return { outcome: "Collected", content: Buffer.concat(chunks) };
50
- };
51
-
52
- export interface ReceiveUploadInput {
53
- /** Decoded storage key, the `/outbox-upload/` prefix already stripped. */
54
- storageKey: string;
55
- exp: string | undefined;
56
- max: string | undefined;
57
- sig: string | undefined;
58
- body: Readable;
59
- nowSeconds: number;
60
- secret: string | undefined;
61
- /**
62
- * Whether the database still holds a live reservation for this attachment.
63
- * Injected rather than reached for, so the receiver stays a function of its
64
- * request and the storage it writes to.
65
- */
66
- findLiveReservation: (
67
- accountConfigId: string,
68
- outboxAttachmentId: string,
69
- nowSeconds: number,
70
- ) => Promise<boolean>;
71
- }
72
-
73
- export interface UploadResult {
74
- status: number;
75
- reason: string;
76
- }
77
-
78
- export const receiveUpload = async (
79
- storage: StorageService,
80
- input: ReceiveUploadInput,
81
- ): Promise<UploadResult> => {
82
- const target = parseOutboxAttachmentKey(input.storageKey);
83
- if (!target) return { status: 404, reason: "not-an-attachment-key" };
84
-
85
- const auth = authorizeUploadRequest({
86
- secret: input.secret,
87
- relativePath: input.storageKey,
88
- exp: input.exp,
89
- max: input.max,
90
- sig: input.sig,
91
- nowSeconds: input.nowSeconds,
92
- });
93
- if (!auth.authorized) {
94
- return { status: auth.status, reason: auth.reason };
95
- }
96
-
97
- // A signed URL outlives the draft it was minted for, so possession of one is
98
- // not enough: the database has to still hold a live reservation for this
99
- // attachment. Without this a URL minted before a discard writes bytes back
100
- // under a draft that no longer exists. The hosted shape cannot make this
101
- // check — block storage receives the PUT directly — which is what the sweep
102
- // exists for.
103
- const reserved = await input.findLiveReservation(
104
- target.accountConfigId,
105
- target.outboxAttachmentId,
106
- input.nowSeconds,
107
- );
108
- if (!reserved) return { status: 409, reason: "no-live-reservation" };
109
-
110
- const collected = await collectLimitedBody(input.body, auth.maxBytes);
111
- if (collected.outcome === "TooLarge") {
112
- return { status: 413, reason: "over-reserved-size" };
113
- }
114
-
115
- // Exactly the reserved size, not merely under it — the same thing S3 binds
116
- // when it recomputes a presigned PUT's signature over the content-length it
117
- // was sent. A short body is a failed upload, and the completion would refuse
118
- // it anyway; refusing here means no partial object is left to refuse.
119
- if (collected.content.length !== auth.maxBytes) {
120
- return { status: 400, reason: "size-mismatch" };
121
- }
122
-
123
- await storage.storeOutboxAttachment({
124
- accountConfigId: target.accountConfigId,
125
- accountId: target.accountId,
126
- outboxMessageId: target.outboxMessageId,
127
- outboxAttachmentId: target.outboxAttachmentId,
128
- content: collected.content,
129
- });
130
-
131
- return { status: 204, reason: "stored" };
132
- };