@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.
- package/dist/dev-server/content-handler.d.ts +38 -0
- package/dist/dev-server/content-handler.js +46 -0
- package/dist/src/api.d.ts +7 -0
- package/dist/src/api.js +144 -0
- package/dist/src/auth.d.ts +30 -0
- package/dist/src/auth.js +73 -0
- package/dist/src/config/msoauth.d.ts +30 -0
- package/dist/src/config/msoauth.js +30 -0
- package/{src/data-backend.ts → dist/src/data-backend.d.ts} +2 -4
- package/dist/src/data-backend.js +12 -0
- package/{src/derive/autoMoved.ts → dist/src/derive/autoMoved.d.ts} +1 -29
- package/dist/src/derive/autoMoved.js +49 -0
- package/dist/src/derive/contentSignature.d.ts +41 -0
- package/{src/derive/contentSignature.ts → dist/src/derive/contentSignature.js} +27 -72
- package/dist/src/derive/contentUrl.d.ts +46 -0
- package/dist/src/derive/contentUrl.js +46 -0
- package/{src/derive/deriveMuted.ts → dist/src/derive/deriveMuted.d.ts} +1 -3
- package/dist/src/derive/deriveMuted.js +7 -0
- package/dist/src/derive/enrichThreadRows.d.ts +118 -0
- package/dist/src/derive/enrichThreadRows.js +151 -0
- package/dist/src/derive/filterThreadCriteria.d.ts +23 -0
- package/dist/src/derive/filterThreadCriteria.js +24 -0
- package/{src/derive/pendingMoveCounts.ts → dist/src/derive/pendingMoveCounts.d.ts} +5 -53
- package/dist/src/derive/pendingMoveCounts.js +53 -0
- package/{src/derive/senderTrust.ts → dist/src/derive/senderTrust.d.ts} +1 -9
- package/dist/src/derive/senderTrust.js +19 -0
- package/dist/src/error.d.ts +21 -0
- package/dist/src/error.js +85 -0
- package/dist/src/handlers/account-guards.d.ts +36 -0
- package/dist/src/handlers/account-guards.js +107 -0
- package/dist/src/handlers/account-oauth.d.ts +14 -0
- package/dist/src/handlers/account-oauth.js +387 -0
- package/dist/src/handlers/account-overrides.d.ts +53 -0
- package/dist/src/handlers/account-overrides.js +233 -0
- package/dist/src/handlers/account-signature.d.ts +28 -0
- package/dist/src/handlers/account-signature.js +85 -0
- package/dist/src/handlers/account.d.ts +6 -0
- package/dist/src/handlers/account.js +350 -0
- package/dist/src/handlers/address.d.ts +35 -0
- package/dist/src/handlers/address.js +141 -0
- package/dist/src/handlers/calendar-event.d.ts +70 -0
- package/dist/src/handlers/calendar-event.js +425 -0
- package/dist/src/handlers/calendar-feed.d.ts +47 -0
- package/dist/src/handlers/calendar-feed.js +192 -0
- package/dist/src/handlers/calendar-suggestion.d.ts +47 -0
- package/dist/src/handlers/calendar-suggestion.js +212 -0
- package/dist/src/handlers/calendar.d.ts +116 -0
- package/dist/src/handlers/calendar.js +330 -0
- package/dist/src/handlers/config.d.ts +43 -0
- package/dist/src/handlers/config.js +176 -0
- package/dist/src/handlers/ensure-account-config.d.ts +9 -0
- package/dist/src/handlers/ensure-account-config.js +27 -0
- package/dist/src/handlers/filter.d.ts +3 -0
- package/dist/src/handlers/filter.js +103 -0
- package/dist/src/handlers/folder-role-appointments.d.ts +68 -0
- package/dist/src/handlers/folder-role-appointments.js +168 -0
- package/dist/src/handlers/folder-role-labels.d.ts +26 -0
- package/dist/src/handlers/folder-role-labels.js +40 -0
- package/dist/src/handlers/folder-role.d.ts +9 -0
- package/dist/src/handlers/folder-role.js +40 -0
- package/dist/src/handlers/index.d.ts +2 -0
- package/dist/src/handlers/index.js +60 -0
- package/dist/src/handlers/label.d.ts +43 -0
- package/dist/src/handlers/label.js +114 -0
- package/dist/src/handlers/mailbox-bindings.d.ts +41 -0
- package/dist/src/handlers/mailbox-bindings.js +61 -0
- package/dist/src/handlers/mailbox.d.ts +23 -0
- package/dist/src/handlers/mailbox.js +219 -0
- package/dist/src/handlers/me.d.ts +2 -0
- package/dist/src/handlers/me.js +138 -0
- package/dist/src/handlers/message.d.ts +84 -0
- package/dist/src/handlers/message.js +607 -0
- package/dist/src/handlers/organize.d.ts +10 -0
- package/dist/src/handlers/organize.js +112 -0
- package/dist/src/handlers/outbox-attachment.d.ts +23 -0
- package/dist/src/handlers/outbox-attachment.js +81 -0
- package/dist/src/handlers/outbox.d.ts +4 -0
- package/dist/src/handlers/outbox.js +144 -0
- package/dist/src/handlers/sync-progress.d.ts +46 -0
- package/dist/src/handlers/sync-progress.js +63 -0
- package/dist/src/handlers/sync.d.ts +28 -0
- package/dist/src/handlers/sync.js +82 -0
- package/dist/src/handlers/system-update.d.ts +2 -0
- package/dist/src/handlers/system-update.js +172 -0
- package/dist/src/handlers/thread.d.ts +148 -0
- package/dist/src/handlers/thread.js +233 -0
- package/dist/src/handlers/unified-threads.d.ts +247 -0
- package/dist/src/handlers/unified-threads.js +409 -0
- package/{src/handlers/vip-suggestions.ts → dist/src/handlers/vip-suggestions.d.ts} +1 -13
- package/dist/src/handlers/vip-suggestions.js +16 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +37 -0
- package/{src/json.ts → dist/src/json.d.ts} +1 -4
- package/dist/src/json.js +10 -0
- package/dist/src/jwt-auth.d.ts +18 -0
- package/dist/src/jwt-auth.js +97 -0
- package/dist/src/request-context.d.ts +9 -0
- package/dist/src/request-context.js +22 -0
- package/dist/src/request.d.ts +3 -0
- package/dist/src/request.js +7 -0
- package/dist/src/response.d.ts +27 -0
- package/dist/src/response.js +87 -0
- package/dist/src/service/account-deletion.d.ts +22 -0
- package/dist/src/service/account-deletion.js +39 -0
- package/dist/src/service/compose-sqlite.d.ts +2 -0
- package/dist/src/service/compose-sqlite.js +11 -0
- package/dist/src/service/create-remit-client.d.ts +95 -0
- package/dist/src/service/create-remit-client.js +207 -0
- package/dist/src/service/data-client.d.ts +12 -0
- package/dist/src/service/data-client.js +44 -0
- package/dist/src/service/export-identity.d.ts +3 -0
- package/dist/src/service/export-identity.js +35 -0
- package/dist/src/service/fire-and-forget.d.ts +54 -0
- package/dist/src/service/fire-and-forget.js +38 -0
- package/dist/src/service/organize.d.ts +184 -0
- package/dist/src/service/organize.js +384 -0
- package/dist/src/service/refusal.d.ts +4 -0
- package/dist/src/service/refusal.js +20 -0
- package/dist/src/service/sender-category.d.ts +51 -0
- package/{src/service/sender-category.ts → dist/src/service/sender-category.js} +40 -81
- package/dist/src/service/sqs.d.ts +1 -0
- package/{src/service/sqs.ts → dist/src/service/sqs.js} +0 -1
- package/dist/src/service/trigger-sync.d.ts +51 -0
- package/dist/src/service/trigger-sync.js +39 -0
- package/dist/src/types.d.ts +49 -0
- package/dist/src/types.js +1 -0
- package/package.json +113 -98
- package/.env.test +0 -4
- package/dev-server/content-auth.test.ts +0 -113
- package/dev-server/content-auth.ts +0 -54
- package/dev-server/content-handler.test.ts +0 -105
- package/dev-server/content-handler.ts +0 -79
- package/dev-server/content-path.test.ts +0 -37
- package/dev-server/content-path.ts +0 -27
- package/dev-server/cors.test.ts +0 -48
- package/dev-server/cors.ts +0 -25
- package/dev-server/keep-alive.test.ts +0 -24
- package/dev-server/lambda-helpers.ts +0 -69
- package/dev-server/log-output.test.ts +0 -173
- package/dev-server/oauth-callback-route.test.ts +0 -66
- package/dev-server/relational-health.test.ts +0 -83
- package/dev-server/relational-health.ts +0 -39
- package/dev-server/server.ts +0 -384
- package/dev-server/sync-age.test.ts +0 -91
- package/dev-server/sync-age.ts +0 -55
- package/dev-server/upload-handler.ts +0 -132
- package/dev-server/upload-route.test.ts +0 -196
- package/scripts/backfill-classification.ts +0 -91
- package/scripts/backfill-list-id.ts +0 -83
- package/scripts/config-save.ts +0 -128
- package/src/auth.test.ts +0 -12
- package/src/auth.ts +0 -92
- package/src/config/msoauth.test.ts +0 -48
- package/src/config/msoauth.ts +0 -48
- package/src/data-backend.test.ts +0 -23
- package/src/derive/autoMoved.test.ts +0 -104
- package/src/derive/contentSignature.test.ts +0 -161
- package/src/derive/contentUrl.test.ts +0 -172
- package/src/derive/contentUrl.ts +0 -86
- package/src/derive/deriveMuted.test.ts +0 -34
- package/src/derive/enrichThreadRows.test.ts +0 -360
- package/src/derive/enrichThreadRows.ts +0 -234
- package/src/derive/filterThreadCriteria.test.ts +0 -126
- package/src/derive/filterThreadCriteria.ts +0 -51
- package/src/derive/senderTrust.test.ts +0 -67
- package/src/error.test.ts +0 -201
- package/src/error.ts +0 -123
- package/src/handlers/account-guards.test.ts +0 -67
- package/src/handlers/account-guards.ts +0 -169
- package/src/handlers/account-oauth-callback.test.ts +0 -155
- package/src/handlers/account-oauth-consent.test.ts +0 -336
- package/src/handlers/account-oauth-start.test.ts +0 -191
- package/src/handlers/account-oauth.test.ts +0 -439
- package/src/handlers/account-oauth.ts +0 -635
- package/src/handlers/account-overrides.test.ts +0 -88
- package/src/handlers/account-overrides.ts +0 -323
- package/src/handlers/account-ownership.ts +0 -25
- package/src/handlers/account-signature.ts +0 -117
- package/src/handlers/account-synced-services.test.ts +0 -245
- package/src/handlers/account.ts +0 -552
- package/src/handlers/address.test.ts +0 -381
- package/src/handlers/address.ts +0 -200
- package/src/handlers/calendar-event.test.ts +0 -685
- package/src/handlers/calendar-event.ts +0 -738
- package/src/handlers/calendar-feed-validators.test.ts +0 -228
- package/src/handlers/calendar-feed.test.ts +0 -439
- package/src/handlers/calendar-feed.ts +0 -291
- package/src/handlers/calendar-sqlite-fixture.ts +0 -36
- package/src/handlers/calendar-suggestion.test.ts +0 -1269
- package/src/handlers/calendar-suggestion.ts +0 -425
- package/src/handlers/calendar.test.ts +0 -1080
- package/src/handlers/calendar.ts +0 -517
- package/src/handlers/config.test.ts +0 -245
- package/src/handlers/config.ts +0 -332
- package/src/handlers/ensure-account-config.ts +0 -30
- package/src/handlers/filter.test.ts +0 -644
- package/src/handlers/filter.ts +0 -518
- package/src/handlers/folder-role-appointments.test.ts +0 -438
- package/src/handlers/folder-role-appointments.ts +0 -249
- package/src/handlers/folder-role-labels.ts +0 -63
- package/src/handlers/folder-role.test.ts +0 -209
- package/src/handlers/folder-role.ts +0 -80
- package/src/handlers/index.ts +0 -83
- package/src/handlers/label.test.ts +0 -112
- package/src/handlers/label.ts +0 -210
- package/src/handlers/mailbox-bindings.ts +0 -111
- package/src/handlers/mailbox-rename-refusal.test.ts +0 -247
- package/src/handlers/mailbox-settled-target.test.ts +0 -251
- package/src/handlers/mailbox.test.ts +0 -280
- package/src/handlers/mailbox.ts +0 -581
- package/src/handlers/me.test.ts +0 -81
- package/src/handlers/me.ts +0 -203
- package/src/handlers/message-read-backfill.test.ts +0 -103
- package/src/handlers/message.test.ts +0 -131
- package/src/handlers/message.ts +0 -1131
- package/src/handlers/organize.test.ts +0 -277
- package/src/handlers/organize.ts +0 -197
- package/src/handlers/outbox-attachment.test.ts +0 -765
- package/src/handlers/outbox-attachment.ts +0 -126
- package/src/handlers/outbox-list.test.ts +0 -179
- package/src/handlers/outbox.test.ts +0 -772
- package/src/handlers/outbox.ts +0 -258
- package/src/handlers/search.test.ts +0 -161
- package/src/handlers/search.ts +0 -105
- package/src/handlers/sync-progress.test.ts +0 -205
- package/src/handlers/sync-progress.ts +0 -99
- package/src/handlers/sync.test.ts +0 -168
- package/src/handlers/sync.ts +0 -118
- package/src/handlers/system-update.test.ts +0 -497
- package/src/handlers/system-update.ts +0 -230
- package/src/handlers/thread.test.ts +0 -396
- package/src/handlers/thread.ts +0 -427
- package/src/handlers/unified-threads-mail-sources.sqlite.test.ts +0 -197
- package/src/handlers/unified-threads.test.ts +0 -785
- package/src/handlers/unified-threads.ts +0 -669
- package/src/index.test.ts +0 -83
- package/src/index.ts +0 -228
- package/src/jwt-auth.test.ts +0 -152
- package/src/jwt-auth.ts +0 -132
- package/src/log-redaction.test.ts +0 -144
- package/src/openapi-spec.test.ts +0 -118
- package/src/request-context.test.ts +0 -61
- package/src/request-context.ts +0 -33
- package/src/request-pipeline.test.ts +0 -88
- package/src/request.ts +0 -10
- package/src/response.test.ts +0 -130
- package/src/response.ts +0 -144
- package/src/service/compose-sqlite.ts +0 -98
- package/src/service/config-import.ts +0 -39
- package/src/service/create-remit-client.ts +0 -522
- package/src/service/data-client.test.ts +0 -97
- package/src/service/data-client.ts +0 -58
- package/src/service/export-identity.ts +0 -39
- package/src/service/filter.ts +0 -40
- package/src/service/fire-and-forget.test.ts +0 -126
- package/src/service/fire-and-forget.ts +0 -79
- package/src/service/localhost-env-config.test.ts +0 -36
- package/src/service/organize-queue.ts +0 -98
- package/src/service/organize.test.ts +0 -1451
- package/src/service/organize.ts +0 -1025
- package/src/service/read-path-filter-config.test.ts +0 -243
- package/src/service/semantic-capability.test.ts +0 -78
- package/src/service/semantic-capability.ts +0 -95
- package/src/service/sender-category.test.ts +0 -235
- package/src/service/trigger-sync.test.ts +0 -205
- package/src/service/trigger-sync.ts +0 -100
- package/src/stdout-capture-fixture.ts +0 -40
- package/src/types.ts +0 -271
- package/tsconfig.json +0 -7
package/dev-server/server.ts
DELETED
|
@@ -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
|
-
});
|
package/dev-server/sync-age.ts
DELETED
|
@@ -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
|
-
};
|