@remit/backend 0.0.144 → 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 (91) hide show
  1. package/dist/src/api.d.ts +0 -2
  2. package/dist/src/api.js +9 -80
  3. package/dist/src/auth.d.ts +13 -19
  4. package/dist/src/auth.js +51 -43
  5. package/dist/src/config/api-config.d.ts +20 -0
  6. package/dist/src/config/api-config.js +1 -0
  7. package/dist/src/config/from-env.d.ts +2 -0
  8. package/dist/src/config/from-env.js +65 -0
  9. package/dist/src/config/msoauth-secrets-manager.d.ts +2 -0
  10. package/dist/src/config/msoauth-secrets-manager.js +28 -0
  11. package/dist/src/config/msoauth.d.ts +18 -29
  12. package/dist/src/config/msoauth.js +4 -28
  13. package/dist/src/derive/contentSignature.d.ts +3 -4
  14. package/dist/src/derive/contentSignature.js +4 -13
  15. package/dist/src/derive/contentUrl.d.ts +0 -13
  16. package/dist/src/derive/contentUrl.js +0 -19
  17. package/dist/src/handlers/account-oauth.d.ts +4 -3
  18. package/dist/src/handlers/account-oauth.js +197 -250
  19. package/dist/src/handlers/account.d.ts +4 -3
  20. package/dist/src/handlers/account.js +26 -39
  21. package/dist/src/handlers/address.d.ts +2 -2
  22. package/dist/src/handlers/address.js +11 -19
  23. package/dist/src/handlers/calendar-event.js +18 -26
  24. package/dist/src/handlers/calendar-feed.d.ts +2 -2
  25. package/dist/src/handlers/calendar-feed.js +24 -32
  26. package/dist/src/handlers/calendar-suggestion.js +18 -26
  27. package/dist/src/handlers/calendar.d.ts +1 -1
  28. package/dist/src/handlers/calendar.js +15 -22
  29. package/dist/src/handlers/config.d.ts +5 -5
  30. package/dist/src/handlers/config.js +23 -35
  31. package/dist/src/handlers/filter.d.ts +2 -2
  32. package/dist/src/handlers/filter.js +17 -32
  33. package/dist/src/handlers/folder-role.js +3 -6
  34. package/dist/src/handlers/index.d.ts +88 -2
  35. package/dist/src/handlers/index.js +6 -4
  36. package/dist/src/handlers/label.js +15 -22
  37. package/dist/src/handlers/mailbox.d.ts +3 -3
  38. package/dist/src/handlers/mailbox.js +18 -26
  39. package/dist/src/handlers/me.d.ts +1 -1
  40. package/dist/src/handlers/me.js +26 -47
  41. package/dist/src/handlers/message.d.ts +3 -5
  42. package/dist/src/handlers/message.js +34 -46
  43. package/dist/src/handlers/organize.d.ts +2 -2
  44. package/dist/src/handlers/organize.js +11 -20
  45. package/dist/src/handlers/outbox-attachment.d.ts +5 -3
  46. package/dist/src/handlers/outbox-attachment.js +11 -15
  47. package/dist/src/handlers/outbox.d.ts +3 -3
  48. package/dist/src/handlers/outbox.js +27 -35
  49. package/dist/src/handlers/sync.d.ts +5 -5
  50. package/dist/src/handlers/sync.js +13 -22
  51. package/dist/src/handlers/system-update.js +14 -27
  52. package/dist/src/handlers/thread.js +9 -14
  53. package/dist/src/handlers/unified-threads.js +3 -6
  54. package/dist/src/index.d.ts +4 -1
  55. package/dist/src/index.js +18 -2
  56. package/dist/src/jwt-auth.d.ts +9 -12
  57. package/dist/src/jwt-auth.js +19 -71
  58. package/dist/src/lambda.d.ts +13 -0
  59. package/dist/src/lambda.js +33 -0
  60. package/dist/src/request.d.ts +8 -2
  61. package/dist/src/request.js +16 -6
  62. package/dist/src/response.d.ts +20 -3
  63. package/dist/src/response.js +15 -22
  64. package/dist/src/serve.d.ts +31 -0
  65. package/dist/src/serve.js +117 -0
  66. package/dist/src/service/account-deletion.d.ts +2 -4
  67. package/dist/src/service/account-deletion.js +3 -9
  68. package/dist/src/service/compose-sqlite.d.ts +2 -1
  69. package/dist/src/service/compose-sqlite.js +5 -3
  70. package/dist/src/service/create-remit-client.d.ts +25 -11
  71. package/dist/src/service/create-remit-client.js +96 -115
  72. package/dist/src/service/data-client.js +3 -5
  73. package/dist/src/service/export-identity.d.ts +2 -1
  74. package/dist/src/service/export-identity.js +4 -6
  75. package/dist/src/service/fire-and-forget.js +1 -2
  76. package/dist/src/service/organize.d.ts +7 -10
  77. package/dist/src/service/organize.js +10 -27
  78. package/dist/src/service/queues.d.ts +12 -0
  79. package/dist/src/service/queues.js +1 -0
  80. package/dist/src/service/shared-deps.d.ts +6 -0
  81. package/dist/src/service/shared-deps.js +24 -0
  82. package/dist/src/service/trigger-sync.d.ts +11 -5
  83. package/dist/src/service/trigger-sync.js +10 -18
  84. package/dist/src/types.d.ts +24 -1
  85. package/package.json +20 -8
  86. package/dist/src/data-backend.d.ts +0 -12
  87. package/dist/src/data-backend.js +0 -12
  88. package/dist/src/request-context.d.ts +0 -9
  89. package/dist/src/request-context.js +0 -22
  90. package/dist/src/service/sqs.d.ts +0 -1
  91. package/dist/src/service/sqs.js +0 -3
@@ -2,15 +2,11 @@ import { randomUUID } from "node:crypto";
2
2
  import { readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { ConflictError, HTTPError } from "@remit/data-ports/errors";
5
- import { getSubFromEvent } from "../auth.js";
6
5
  import { defaultErrorCode } from "../error.js";
7
6
  class TargetVersionMismatchError extends HTTPError {
8
7
  name = "TargetVersionMismatchError";
9
8
  statusCode = 422;
10
9
  }
11
- const DEFAULT_CONTROL_DIR = "/data/control";
12
- const controlDir = () => process.env.REMIT_UPDATE_CONTROL_DIR ?? DEFAULT_CONTROL_DIR;
13
- const manifestUrl = () => process.env.REMIT_UPDATE_MANIFEST_URL;
14
10
  /**
15
11
  * The running version is a fact the updater owns: it reads the tag from `.env`
16
12
  * and writes it into `state.json`, which is the only honest source across an
@@ -33,24 +29,23 @@ const refuse = (statusCode, message) => ({
33
29
  body: { code: defaultErrorCode(statusCode), message },
34
30
  });
35
31
  const notFound = () => refuse(404, "Not found");
36
- const unauthorized = () => refuse(401, "Unauthorized");
37
32
  /**
38
33
  * The self-update seam is off unless a manifest URL is configured (RFC 037 D8),
39
34
  * which the hosted deployment leaves unset. Both endpoints answer 404 in that
40
35
  * case. Where it is on, any authenticated caller may use it — the account list
41
36
  * is the trust boundary of a self-hosted instance.
42
37
  */
43
- const guardManifestConfigured = () => manifestUrl() ? null : notFound();
38
+ const guardManifestConfigured = (config) => config.selfUpdate === "Enabled" ? null : notFound();
44
39
  /**
45
40
  * Read `state.json` off the control volume. A missing file is an instance that
46
41
  * has never checked or updated (RFC 037 D8), not an error, so it reads as
47
42
  * `null`; the caller renders the disabled/empty resource. Any other read or
48
43
  * parse failure propagates.
49
44
  */
50
- const readState = () => {
45
+ const readState = (controlDir) => {
51
46
  let raw;
52
47
  try {
53
- raw = readFileSync(join(controlDir(), "state.json"), "utf8");
48
+ raw = readFileSync(join(controlDir, "state.json"), "utf8");
54
49
  }
55
50
  catch (error) {
56
51
  if (error.code === "ENOENT")
@@ -78,8 +73,7 @@ const checkedVersion = (state) => state?.check.status === "ok" ? state.check.lat
78
73
  * so the backend never writes one. The temp file sits in the same directory as
79
74
  * the target for the rename to be atomic on one filesystem.
80
75
  */
81
- const writeRequest = (request) => {
82
- const dir = controlDir();
76
+ const writeRequest = (dir, request) => {
83
77
  const tmp = join(dir, `.request.json.${request.runId}.tmp`);
84
78
  writeFileSync(tmp, JSON.stringify(request), { mode: 0o644 });
85
79
  renameSync(tmp, join(dir, "request.json"));
@@ -91,8 +85,7 @@ const writeRequest = (request) => {
91
85
  * request.json it is written atomically and carries no authority: its presence
92
86
  * is the whole message, so it is empty.
93
87
  */
94
- const writeCheckRequest = () => {
95
- const dir = controlDir();
88
+ const writeCheckRequest = (dir) => {
96
89
  const tmp = join(dir, `.check-request.json.tmp`);
97
90
  writeFileSync(tmp, JSON.stringify({}), { mode: 0o644 });
98
91
  renameSync(tmp, join(dir, "check-request.json"));
@@ -123,13 +116,10 @@ const requestedResource = (state, runId, targetVersion, requestedAt) => {
123
116
  };
124
117
  };
125
118
  export const SystemOperations = {
126
- SystemOperations_getSystemUpdate: async (context, ...args) => {
127
- const offSurface = guardManifestConfigured();
119
+ SystemOperations_getSystemUpdate: async (context, ctx) => {
120
+ const offSurface = guardManifestConfigured(ctx.config);
128
121
  if (offSurface)
129
122
  return offSurface;
130
- const event = args[0];
131
- if (!getSubFromEvent(event))
132
- return unauthorized();
133
123
  // A refresh asks for a fresh answer, and only the updater can fetch one, so
134
124
  // the request goes on the control seam for its watch loop to pick up (#599).
135
125
  // The answer is the stored state as it stands, `lastCheckedAt` included: the
@@ -137,19 +127,16 @@ export const SystemOperations = {
137
127
  // response never has to claim a verdict the updater has not reached.
138
128
  const { refresh } = context.request.query;
139
129
  if (refresh === true)
140
- writeCheckRequest();
141
- return readState() ?? emptyResource();
130
+ writeCheckRequest(ctx.config.updateControlDir);
131
+ return readState(ctx.config.updateControlDir) ?? emptyResource();
142
132
  },
143
- SystemOperations_applySystemUpdate: async (context, ...args) => {
144
- const offSurface = guardManifestConfigured();
133
+ SystemOperations_applySystemUpdate: async (context, ctx) => {
134
+ const offSurface = guardManifestConfigured(ctx.config);
145
135
  if (offSurface)
146
136
  return offSurface;
147
- const event = args[0];
148
- const sub = getSubFromEvent(event);
149
- if (!sub)
150
- return unauthorized();
137
+ const sub = ctx.identity.sub;
151
138
  const { targetVersion } = context.request.requestBody;
152
- const state = readState();
139
+ const state = readState(ctx.config.updateControlDir);
153
140
  if (isRunInFlight(state?.run)) {
154
141
  throw new ConflictError("An update is already in progress.");
155
142
  }
@@ -158,7 +145,7 @@ export const SystemOperations = {
158
145
  }
159
146
  const runId = randomUUID();
160
147
  const requestedAt = new Date().toISOString();
161
- writeRequest({
148
+ writeRequest(ctx.config.updateControlDir, {
162
149
  runId,
163
150
  targetVersion,
164
151
  requestedAt,
@@ -1,9 +1,7 @@
1
1
  import { NotFoundError } from "@remit/data-ports/errors";
2
2
  import { isValidMessageId } from "@remit/data-ports/id";
3
- import { getAccountConfigIdFromEvent } from "../auth.js";
4
3
  import { enrichThreadRows, } from "../derive/enrichThreadRows.js";
5
4
  import { filterByOffRowCriteria, hasOffRowCriteria, } from "../derive/filterThreadCriteria.js";
6
- import { getClient } from "../service/data-client.js";
7
5
  const DEFAULT_THREADS_PAGE_SIZE = 50;
8
6
  // Project to fields used by ListThreadsResponse — keep in sync with handler mapping.
9
7
  // Includes ThreadMessage table key fields (accountConfigId, threadMessageId) and
@@ -183,12 +181,11 @@ export const dedupeThreadMessages = (rows) => {
183
181
  });
184
182
  };
185
183
  export const ThreadOperations = {
186
- ThreadOperations_listThreads: async (context, ...args) => {
187
- const event = args[0];
188
- const accountConfigId = getAccountConfigIdFromEvent(event);
184
+ ThreadOperations_listThreads: async (context, ctx) => {
185
+ const accountConfigId = ctx.identity.accountConfigId;
189
186
  const { mailboxId } = context.request.params;
190
187
  const { continuationToken, order } = context.request.query;
191
- const client = await getClient();
188
+ const client = ctx.client;
192
189
  const result = await client.threadMessage.listByMailbox(accountConfigId, mailboxId, buildListThreadsOptions({ continuationToken, order }));
193
190
  const items = await enrichThreadRows(result.items, client, accountConfigId);
194
191
  return {
@@ -196,12 +193,11 @@ export const ThreadOperations = {
196
193
  continuationToken: result.continuationToken,
197
194
  };
198
195
  },
199
- ThreadOperations_searchThreads: async (context, ...args) => {
200
- const event = args[0];
201
- const accountConfigId = getAccountConfigIdFromEvent(event);
196
+ ThreadOperations_searchThreads: async (context, ctx) => {
197
+ const accountConfigId = ctx.identity.accountConfigId;
202
198
  const { mailboxId } = context.request.params;
203
199
  const raw = context.request.query;
204
- return executeThreadSearch(await getClient(), accountConfigId, mailboxId, {
200
+ return executeThreadSearch(ctx.client, accountConfigId, mailboxId, {
205
201
  ...raw,
206
202
  senderTrust: toArray(raw.senderTrust),
207
203
  category: toArray(raw.category),
@@ -209,12 +205,11 @@ export const ThreadOperations = {
209
205
  },
210
206
  };
211
207
  export const ThreadDetailOperations = {
212
- ThreadDetailOperations_listThreadMessages: async (context, ...args) => {
213
- const event = args[0];
214
- const accountConfigId = getAccountConfigIdFromEvent(event);
208
+ ThreadDetailOperations_listThreadMessages: async (context, ctx) => {
209
+ const accountConfigId = ctx.identity.accountConfigId;
215
210
  const { threadId } = context.request.params;
216
211
  const { order } = context.request.query;
217
- const client = await getClient();
212
+ const client = ctx.client;
218
213
  const result = await client.threadMessage.listByThread(threadId, accountConfigId, buildListThreadMessagesOptions({ order }));
219
214
  // Defense-in-depth: the query is already scoped to accountConfigId; assert
220
215
  // no foreign row slips into the response. `read`-class → 404, no existence
@@ -1,9 +1,7 @@
1
1
  import { isVirtualCopyMailbox } from "@remit/data-ports/virtual-copy";
2
2
  import { MailboxSpecialUse } from "@remit/domain-enums";
3
3
  import pMap from "p-map";
4
- import { getAccountConfigIdFromEvent } from "../auth.js";
5
4
  import { enrichThreadRows, } from "../derive/enrichThreadRows.js";
6
- import { getClient } from "../service/data-client.js";
7
5
  import { groupAccountOverrides, groupMailboxOverrides, } from "./account-overrides.js";
8
6
  const DEFAULT_UNIFIED_THREADS_PAGE_SIZE = 50;
9
7
  const MAILBOX_LIST_CONCURRENCY = 5;
@@ -374,9 +372,8 @@ const toBoolean = (value) => {
374
372
  return undefined;
375
373
  };
376
374
  export const UnifiedThreadOperations = {
377
- UnifiedThreadOperations_listAllThreads: async (context, ...args) => {
378
- const event = args[0];
379
- const accountConfigId = getAccountConfigIdFromEvent(event);
375
+ UnifiedThreadOperations_listAllThreads: async (context, ctx) => {
376
+ const accountConfigId = ctx.identity.accountConfigId;
380
377
  const { continuationToken, order, limit, starred, query, from, subject, accountId, mailboxId, category, unread, attachments, muted, count, results, } = context.request.query;
381
378
  // Whitespace-only text is not a search: it would widen the scope to every
382
379
  // folder while matching nothing in particular. The two field parameters
@@ -384,7 +381,7 @@ export const UnifiedThreadOperations = {
384
381
  const searchText = query?.trim();
385
382
  const fromText = from?.trim();
386
383
  const subjectText = subject?.trim();
387
- return executeUnifiedThreadListing(await getClient(), accountConfigId, {
384
+ return executeUnifiedThreadListing(ctx.client, accountConfigId, {
388
385
  continuationToken,
389
386
  order,
390
387
  limit,
@@ -1,2 +1,5 @@
1
- export { api, handleApiEvent, OpenAPISpec } from "./api.js";
1
+ export { OpenAPISpec } from "./api.js";
2
+ export { configFromEnv } from "./config/from-env.js";
3
+ export { createApiEventHandler } from "./lambda.js";
4
+ export declare const handleApiEvent: import("./lambda.js").ApiEventHandler;
2
5
  export declare const handler: (event: import("aws-lambda").APIGatewayProxyEvent, context: import("aws-lambda").Context) => Promise<import("aws-lambda").APIGatewayProxyResult>;
package/dist/src/index.js CHANGED
@@ -1,9 +1,25 @@
1
1
  import { inspect } from "node:util";
2
2
  import { logger } from "@remit/logger";
3
3
  import { installNodeLogger, withTelemetry } from "@remit/logger-lambda/node";
4
- import { handleApiEvent } from "./api.js";
4
+ import { assertLocalBypassNotInDeployedEnv, localIdentityFromEnv, } from "./auth.js";
5
+ import { configFromEnv } from "./config/from-env.js";
6
+ import { handlers, publicHandlers } from "./handlers/index.js";
7
+ import { createApiEventHandler } from "./lambda.js";
8
+ import { serveApi } from "./serve.js";
9
+ import { getClient } from "./service/data-client.js";
5
10
  installNodeLogger();
6
- export { api, handleApiEvent, OpenAPISpec } from "./api.js";
11
+ // Boot-time refusal: the local auth-bypass overrides must never be set in a
12
+ // deployed environment, so a leaked env var fails the process at startup
13
+ // rather than silently opening authentication at runtime.
14
+ assertLocalBypassNotInDeployedEnv(process.env);
15
+ export { OpenAPISpec } from "./api.js";
16
+ export { configFromEnv } from "./config/from-env.js";
17
+ export { createApiEventHandler } from "./lambda.js";
18
+ export const handleApiEvent = createApiEventHandler(serveApi({ handlers, publicHandlers }), {
19
+ config: configFromEnv(process.env),
20
+ localIdentity: localIdentityFromEnv(process.env),
21
+ client: getClient,
22
+ });
7
23
  /**
8
24
  * Last-resort net for a leaked background rejection.
9
25
  *
@@ -1,18 +1,15 @@
1
1
  import { type JwtVerifier } from "@remit/auth-service/verifier";
2
- import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
2
+ import type { APIGatewayProxyEvent } from "aws-lambda";
3
+ import type { HandlerIdentity } from "./types.js";
3
4
  /** Test-only override for the memoized verifier. Pass null to reset. */
4
5
  export declare const _setVerifierForTest: (v: JwtVerifier | null) => void;
5
6
  /**
6
- * Authenticate a self-host request from a better-auth RS256 JWT.
7
+ * The caller of a self-host request, from a better-auth RS256 JWT.
7
8
  *
8
- * On a valid token the verified `sub` is injected into the event's authorizer
9
- * claims, exactly where the Cognito authorizer puts it, so every downstream
10
- * handler reads identity through the unchanged `auth.ts` path. Returns a 401
11
- * response (not null) when a token is present but fails verification, or when no
12
- * token is supplied and no local test bypass is configured. Returns null to let
13
- * the request proceed.
14
- *
15
- * When the edge tier (APISIX) has already verified and injected claims, this is
16
- * a no-op — the backend trusts pre-populated claims and re-verifies otherwise.
9
+ * Claims the edge tier (APISIX) already verified and injected are trusted as
10
+ * they stand. Otherwise a bearer token is verified here; a token that fails
11
+ * verification is an anonymous caller, so the operation table answers 401. With
12
+ * no token the local bypass, when one is configured, is the caller. Only an
13
+ * operation that needs a caller resolves this, so a public route never does.
17
14
  */
18
- export declare const authenticateSelfHostRequest: (event: APIGatewayProxyEvent) => Promise<APIGatewayProxyResult | null>;
15
+ export declare const identityFromSelfHostRequest: (event: APIGatewayProxyEvent, localIdentity: HandlerIdentity) => Promise<HandlerIdentity>;
@@ -1,8 +1,6 @@
1
1
  import { createJwtVerifier, extractBearerToken, resolveVerifierConfig, } from "@remit/auth-service/verifier";
2
- import { readCalendarFeedToken } from "@remit/calendar-service";
3
2
  import { logger } from "@remit/logger";
4
- import { defaultErrorCode } from "./error.js";
5
- import { formatResponse } from "./response.js";
3
+ import { getIdentityFromEvent, userFromClaims } from "./auth.js";
6
4
  let verifier = null;
7
5
  const getVerifier = () => {
8
6
  if (!verifier)
@@ -20,78 +18,28 @@ const readHeader = (headers, name) => {
20
18
  }
21
19
  return undefined;
22
20
  };
23
- const unauthorized = (message) => formatResponse({ code: defaultErrorCode(401), message }, 401);
24
- const injectClaims = (event, sub, email) => {
25
- event.requestContext = {
26
- ...event.requestContext,
27
- authorizer: {
28
- ...event.requestContext?.authorizer,
29
- claims: {
30
- sub,
31
- email: email ?? "",
32
- },
33
- },
34
- };
35
- };
36
- const hasLocalBypass = () => Boolean(process.env.LOCAL_ACCOUNT_CONFIG_ID);
37
- /**
38
- * The routes that run without a token.
39
- *
40
- * Microsoft redirects the browser here after consent, so the request carries no
41
- * Authorization header and no session to derive one from. The handler takes its
42
- * identity from the HMAC-signed `state` parameter and validates it there; that
43
- * signature is the gate, not a JWT.
44
- *
45
- * A calendar feed carries its credential in the path for the same reason: a
46
- * subscribed client polls a URL and has no session to present, so the token is
47
- * the gate and the handler compares it against a stored hash.
48
- *
49
- * Hardcoded, matching the edge exemption in packages/apisix/src/route-table.ts.
50
- * `@useAuth(NoAuth)` in the spec does not open a path here — a future public
51
- * route is added by hand.
52
- */
53
- const PUBLIC_ROUTES = [
54
- {
55
- method: "GET",
56
- matches: (path) => path === "/accounts/oauth/microsoft/callback",
57
- },
58
- {
59
- method: "GET",
60
- matches: (path) => readCalendarFeedToken(path) !== null,
61
- },
62
- ];
63
- const isPublicRoute = (event) => PUBLIC_ROUTES.some((route) => route.method === event.httpMethod && route.matches(event.path));
64
21
  /**
65
- * Authenticate a self-host request from a better-auth RS256 JWT.
22
+ * The caller of a self-host request, from a better-auth RS256 JWT.
66
23
  *
67
- * On a valid token the verified `sub` is injected into the event's authorizer
68
- * claims, exactly where the Cognito authorizer puts it, so every downstream
69
- * handler reads identity through the unchanged `auth.ts` path. Returns a 401
70
- * response (not null) when a token is present but fails verification, or when no
71
- * token is supplied and no local test bypass is configured. Returns null to let
72
- * the request proceed.
73
- *
74
- * When the edge tier (APISIX) has already verified and injected claims, this is
75
- * a no-op — the backend trusts pre-populated claims and re-verifies otherwise.
24
+ * Claims the edge tier (APISIX) already verified and injected are trusted as
25
+ * they stand. Otherwise a bearer token is verified here; a token that fails
26
+ * verification is an anonymous caller, so the operation table answers 401. With
27
+ * no token the local bypass, when one is configured, is the caller. Only an
28
+ * operation that needs a caller resolves this, so a public route never does.
76
29
  */
77
- export const authenticateSelfHostRequest = async (event) => {
78
- if (isPublicRoute(event))
79
- return null;
30
+ export const identityFromSelfHostRequest = async (event, localIdentity) => {
80
31
  const existingSub = event.requestContext?.authorizer?.claims?.sub;
81
- if (typeof existingSub === "string" && existingSub.length > 0)
82
- return null;
83
- const token = extractBearerToken(readHeader(event.headers, "authorization"));
84
- if (token) {
85
- const claims = await getVerifier()(token).catch((err) => {
86
- logger.warn({ err: err instanceof Error ? err.message : String(err) }, "better-auth JWT verification failed");
87
- return null;
88
- });
89
- if (!claims)
90
- return unauthorized("Invalid or expired token");
91
- injectClaims(event, claims.sub, claims.email);
92
- return null;
32
+ if (typeof existingSub === "string" && existingSub.length > 0) {
33
+ return getIdentityFromEvent(event, localIdentity);
93
34
  }
94
- if (hasLocalBypass())
35
+ const token = extractBearerToken(readHeader(event.headers, "authorization"));
36
+ if (!token)
37
+ return localIdentity;
38
+ const claims = await getVerifier()(token).catch((err) => {
39
+ logger.warn({ err: err instanceof Error ? err.message : String(err) }, "better-auth JWT verification failed");
95
40
  return null;
96
- return unauthorized("Authentication required");
41
+ });
42
+ if (!claims)
43
+ return { kind: "anonymous" };
44
+ return userFromClaims(claims.sub, claims.email);
97
45
  };
@@ -0,0 +1,13 @@
1
+ import type { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from "aws-lambda";
2
+ import type { ApiConfig } from "./config/api-config.js";
3
+ import type { ServedApi } from "./serve.js";
4
+ import type { RemitClient } from "./service/data-client.js";
5
+ import type { QueueName } from "./service/queues.js";
6
+ import type { HandlerIdentity } from "./types.js";
7
+ export interface ApiEventHandlerOptions<K extends QueueName> {
8
+ config: ApiConfig;
9
+ localIdentity: HandlerIdentity;
10
+ client: () => Promise<RemitClient<K>>;
11
+ }
12
+ export type ApiEventHandler = (event: APIGatewayProxyEvent, context: Context) => Promise<APIGatewayProxyResult>;
13
+ export declare const createApiEventHandler: <K extends QueueName>(served: ServedApi<K>, { config, localIdentity, client }: ApiEventHandlerOptions<NoInfer<K>>) => ApiEventHandler;
@@ -0,0 +1,33 @@
1
+ import { getIdentityFromEvent } from "./auth.js";
2
+ import { identityFromSelfHostRequest } from "./jwt-auth.js";
3
+ import { normalizeRequest } from "./request.js";
4
+ const queryOf = (event) => {
5
+ if (event.multiValueQueryStringParameters) {
6
+ return event.multiValueQueryStringParameters;
7
+ }
8
+ return Object.fromEntries(Object.entries(event.queryStringParameters ?? {}).map(([key, value]) => [
9
+ key,
10
+ value === undefined ? undefined : [value],
11
+ ]));
12
+ };
13
+ const headersOf = (event) => ({
14
+ ...event.headers,
15
+ ...Object.fromEntries(Object.entries(event.multiValueHeaders ?? {}).map(([name, values]) => [
16
+ name,
17
+ values?.join(", "),
18
+ ])),
19
+ });
20
+ const normalizeApiGatewayEvent = (event) => normalizeRequest({
21
+ method: event.httpMethod,
22
+ path: event.path,
23
+ query: queryOf(event),
24
+ headers: headersOf(event),
25
+ body: event.body,
26
+ });
27
+ export const createApiEventHandler = (served, { config, localIdentity, client }) => (event, context) => served.dispatch(normalizeApiGatewayEvent(event), {
28
+ config,
29
+ client,
30
+ identity: async () => config.auth.mode === "BetterAuthJwt"
31
+ ? identityFromSelfHostRequest(event, localIdentity)
32
+ : getIdentityFromEvent(event, localIdentity),
33
+ }, context.awsRequestId);
@@ -1,3 +1,9 @@
1
- import type { APIGatewayProxyEvent } from "aws-lambda";
2
1
  import type { Request } from "openapi-backend";
3
- export declare const normalizeRequest: (event: APIGatewayProxyEvent) => Request;
2
+ export interface HostRequest {
3
+ method: string;
4
+ path: string;
5
+ query: Readonly<Record<string, readonly string[] | undefined>> | null;
6
+ headers: Readonly<Record<string, string | undefined>> | null;
7
+ body: string | null;
8
+ }
9
+ export declare const normalizeRequest: (request: HostRequest) => Request;
@@ -1,7 +1,17 @@
1
- export const normalizeRequest = (event) => ({
2
- method: event.httpMethod,
3
- path: event.path,
4
- query: (event.queryStringParameters ?? {}),
5
- body: event.body,
6
- headers: (event.headers ?? {}),
1
+ const definedHeaders = (map) => Object.fromEntries(Object.entries(map ?? {}).filter((entry) => entry[1] !== undefined));
2
+ const collapsedQuery = (map) => {
3
+ const query = {};
4
+ for (const [key, values] of Object.entries(map ?? {})) {
5
+ if (!values || values.length === 0)
6
+ continue;
7
+ query[key] = values.length === 1 ? values[0] : [...values];
8
+ }
9
+ return query;
10
+ };
11
+ export const normalizeRequest = (request) => ({
12
+ method: request.method,
13
+ path: request.path,
14
+ query: collapsedQuery(request.query),
15
+ body: request.body === "" ? null : request.body,
16
+ headers: definedHeaders(request.headers),
7
17
  });
@@ -1,5 +1,6 @@
1
1
  import type { APIGatewayProxyResult } from "aws-lambda";
2
2
  import type { Context as OpenAPIContext } from "openapi-backend";
3
+ import type { ApiConfig } from "./config/api-config.js";
3
4
  declare const RAW_API_RESPONSE: unique symbol;
4
5
  /**
5
6
  * A response the caller reads by something other than its JSON body — an
@@ -10,8 +11,8 @@ declare const RAW_API_RESPONSE: unique symbol;
10
11
  * stays the default; a handler that has to own its own media type or headers
11
12
  * says so with `rawApiResponse` rather than by returning a shape this one has
12
13
  * to guess at.
13
- * The CORS and correlation headers are still added here, so a raw response is
14
- * not a way around them.
14
+ * `dispatch` still adds the envelope headers, so a raw response is not a way
15
+ * around them.
15
16
  */
16
17
  export interface RawApiResponse {
17
18
  statusCode: number;
@@ -22,6 +23,22 @@ type MarkedRawApiResponse = RawApiResponse & {
22
23
  readonly [RAW_API_RESPONSE]: true;
23
24
  };
24
25
  export declare const rawApiResponse: (response: RawApiResponse) => MarkedRawApiResponse;
26
+ /**
27
+ * The headers every response carries whatever its body is: the CORS grant the
28
+ * browser needs, and the id the request's log lines are already tagged with.
29
+ * Returning that id is what makes a bug report's "correlation id" resolve to a
30
+ * server-side line; the browser can only read a non-safelisted header when it
31
+ * is exposed.
32
+ */
33
+ export interface EnvelopeInput {
34
+ origin: string | undefined;
35
+ allowedOrigins: readonly string[];
36
+ correlationId: string;
37
+ }
38
+ export declare const resolveAllowedOrigin: (origin: string | undefined, allowed: readonly string[]) => string;
39
+ export declare const envelopeHeaders: ({ origin, allowedOrigins, correlationId, }: EnvelopeInput) => Record<string, string>;
25
40
  export declare const formatResponse: (body: Record<string, unknown>, statusCode?: number) => APIGatewayProxyResult;
26
- export declare const postResponseHandler: (context: OpenAPIContext) => APIGatewayProxyResult;
41
+ export declare const postResponseHandler: (context: OpenAPIContext, scope: {
42
+ config: ApiConfig;
43
+ }) => APIGatewayProxyResult;
27
44
  export {};
@@ -1,17 +1,16 @@
1
1
  import { logger } from "@remit/logger";
2
- import { getRequestCorrelationId, getRequestOrigin, resolveAllowedOrigin, } from "./request-context.js";
3
2
  const RAW_API_RESPONSE = Symbol("remit.rawApiResponse");
4
3
  export const rawApiResponse = (response) => Object.assign(response, { [RAW_API_RESPONSE]: true });
5
4
  const isRawApiResponse = (body) => RAW_API_RESPONSE in body;
6
- /**
7
- * The headers every response carries whatever its body is: the CORS grant the
8
- * browser needs, and the id the request's log lines are already tagged with.
9
- * Returning that id is what makes a bug report's "correlation id" resolve to a
10
- * server-side line; the browser can only read a non-safelisted header when it
11
- * is exposed.
12
- */
13
- const envelopeHeaders = () => {
14
- const allowOrigin = resolveAllowedOrigin(getRequestOrigin());
5
+ export const resolveAllowedOrigin = (origin, allowed) => {
6
+ if (allowed.length === 0)
7
+ return "*";
8
+ if (origin && allowed.includes(origin))
9
+ return origin;
10
+ return allowed[0];
11
+ };
12
+ export const envelopeHeaders = ({ origin, allowedOrigins, correlationId, }) => {
13
+ const allowOrigin = resolveAllowedOrigin(origin, allowedOrigins);
15
14
  const headers = {
16
15
  "Access-Control-Allow-Origin": allowOrigin,
17
16
  "Access-Control-Allow-Headers": "Authorization,Content-Type",
@@ -21,11 +20,8 @@ const envelopeHeaders = () => {
21
20
  if (allowOrigin !== "*") {
22
21
  headers["Access-Control-Allow-Credentials"] = "true";
23
22
  }
24
- const correlationId = getRequestCorrelationId();
25
- if (correlationId) {
26
- headers["x-correlation-id"] = correlationId;
27
- headers["Access-Control-Expose-Headers"] = "x-correlation-id";
28
- }
23
+ headers["x-correlation-id"] = correlationId;
24
+ headers["Access-Control-Expose-Headers"] = "x-correlation-id";
29
25
  return headers;
30
26
  };
31
27
  export const formatResponse = (body, statusCode = 200) => {
@@ -36,7 +32,7 @@ export const formatResponse = (body, statusCode = 200) => {
36
32
  logger.debug({ statusCode: body.statusCode }, "response");
37
33
  return {
38
34
  statusCode: body.statusCode,
39
- headers: { ...envelopeHeaders(), ...body.headers },
35
+ headers: body.headers,
40
36
  body: body.body,
41
37
  };
42
38
  }
@@ -46,18 +42,15 @@ export const formatResponse = (body, statusCode = 200) => {
46
42
  logger.debug({ statusCode }, "response");
47
43
  return {
48
44
  statusCode: statusCode,
49
- headers: {
50
- "Content-Type": "application/json",
51
- ...envelopeHeaders(),
52
- },
45
+ headers: { "Content-Type": "application/json" },
53
46
  body: JSON.stringify(body),
54
47
  };
55
48
  };
56
- export const postResponseHandler = (context) => {
49
+ export const postResponseHandler = (context, scope) => {
57
50
  const { api, response, operation } = context;
58
51
  if (response.statusCode)
59
52
  return formatResponse(response, response.statusCode);
60
- if (process.env.STAGE_NAME !== "dev")
53
+ if (scope.config.responseValidation === "Skip")
61
54
  return formatResponse(response, 200);
62
55
  const { valid, errors } = api.validateResponse(response, operation);
63
56
  if (valid)
@@ -0,0 +1,31 @@
1
+ import type { APIGatewayProxyResult } from "aws-lambda";
2
+ import type { Request as OpenAPIRequest } from "openapi-backend";
3
+ import type { ApiConfig } from "./config/api-config.js";
4
+ import type { RemitClient } from "./service/data-client.js";
5
+ import type { QueueName } from "./service/queues.js";
6
+ import type { HandlerIdentity, OperationHandler, OperationIds, PublicOperationHandler, PublicOperationIds } from "./types.js";
7
+ export type { ApiConfig } from "./config/api-config.js";
8
+ export type { HandlerContext, HandlerIdentity, PublicHandlerContext, UserIdentity, } from "./types.js";
9
+ export interface RequestHost<K extends QueueName> {
10
+ config: ApiConfig;
11
+ client: () => Promise<RemitClient<K>>;
12
+ identity: () => Promise<HandlerIdentity>;
13
+ }
14
+ /**
15
+ * The operations a host serves, each typed by the queues it sends to. An
16
+ * operation the host leaves out answers 501.
17
+ */
18
+ export interface ApiRegistry<K extends QueueName> {
19
+ handlers: Partial<Record<Exclude<OperationIds, PublicOperationIds>, OperationHandler<OperationIds, K>>>;
20
+ publicHandlers: Partial<Record<PublicOperationIds, PublicOperationHandler<K>>>;
21
+ }
22
+ export interface ServedApi<K extends QueueName> {
23
+ dispatch: (request: OpenAPIRequest, host: RequestHost<K>, correlationId: string) => Promise<APIGatewayProxyResult>;
24
+ handleFetch: (request: Request, host: RequestHost<K>, correlationId: string) => Promise<Response>;
25
+ }
26
+ /**
27
+ * Register a host's operations and return the two ways in: a normalised
28
+ * request, or a Fetch `Request`. Each request's host supplies the client, and
29
+ * the client's queues must cover every operation in the registry.
30
+ */
31
+ export declare const serveApi: <K extends QueueName>(registry: ApiRegistry<NoInfer<K>>) => ServedApi<K>;