@remit/backend 0.0.144 → 0.0.146

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 +9 -3
  18. package/dist/src/handlers/account-oauth.js +197 -250
  19. package/dist/src/handlers/account.d.ts +13 -4
  20. package/dist/src/handlers/account.js +26 -39
  21. package/dist/src/handlers/address.d.ts +11 -3
  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 +16 -6
  30. package/dist/src/handlers/config.js +23 -35
  31. package/dist/src/handlers/filter.d.ts +16 -3
  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 +187 -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 +20 -4
  38. package/dist/src/handlers/mailbox.js +18 -26
  39. package/dist/src/handlers/me.d.ts +10 -2
  40. package/dist/src/handlers/me.js +26 -47
  41. package/dist/src/handlers/message.d.ts +78 -6
  42. package/dist/src/handlers/message.js +34 -46
  43. package/dist/src/handlers/organize.d.ts +10 -4
  44. package/dist/src/handlers/organize.js +11 -20
  45. package/dist/src/handlers/outbox-attachment.d.ts +6 -5
  46. package/dist/src/handlers/outbox-attachment.js +11 -15
  47. package/dist/src/handlers/outbox.d.ts +25 -4
  48. package/dist/src/handlers/outbox.js +27 -35
  49. package/dist/src/handlers/sync.d.ts +19 -6
  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 +97 -116
  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 +26 -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 +24 -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
package/dist/src/api.d.ts CHANGED
@@ -1,7 +1,5 @@
1
- import type { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from "aws-lambda";
2
1
  import { type Document, OpenAPIBackend } from "openapi-backend";
3
2
  export declare const OpenAPISpec: Document;
4
3
  declare const api: OpenAPIBackend;
5
4
  export { setLogger } from "@remit/logger";
6
5
  export { api };
7
- export declare const handleApiEvent: (event: APIGatewayProxyEvent, context: Context) => Promise<APIGatewayProxyResult>;
package/dist/src/api.js CHANGED
@@ -1,19 +1,9 @@
1
1
  import spec from "@remit/api-openapi-spec/openapi.json" with { type: "json" };
2
2
  import { redactCalendarFeedPath } from "@remit/calendar-service";
3
- import { logger, withLogContext } from "@remit/logger";
3
+ import { logger } from "@remit/logger";
4
4
  import { OpenAPIBackend, } from "openapi-backend";
5
- import { assertLocalBypassNotInDeployedEnv } from "./auth.js";
6
- import { usesBetterAuthJwt } from "./data-backend.js";
7
- import { defaultErrorCode, handleError } from "./error.js";
8
- import { handlers } from "./handlers/index.js";
9
- import { authenticateSelfHostRequest } from "./jwt-auth.js";
10
- import { normalizeRequest } from "./request.js";
11
- import { runWithRequestContext } from "./request-context.js";
12
- import { formatResponse, postResponseHandler } from "./response.js";
13
- // Boot-time refusal: the local auth-bypass overrides must never be set in a
14
- // deployed environment. Runs on module load so a leaked env var fails the
15
- // process at startup rather than silently opening authentication at runtime.
16
- assertLocalBypassNotInDeployedEnv();
5
+ import { defaultErrorCode } from "./error.js";
6
+ import { postResponseHandler } from "./response.js";
17
7
  const isDocument = (value) => typeof value === "object" &&
18
8
  value !== null &&
19
9
  "openapi" in value &&
@@ -55,10 +45,10 @@ const validationDetails = (errors) => (errors ?? []).map((error) => ({
55
45
  // holds the caller's headers and cookies — an Authorization bearer among them,
56
46
  // and for a calendar feed the token in the path is itself the credential
57
47
  // (issue #1065). The path is redacted the same way the request scope redacts it.
58
- api.register("validationFail", (c, req) => {
48
+ api.register("validationFail", (c) => {
59
49
  logger.error({
60
50
  errors: c.validation.errors,
61
- path: redactCalendarFeedPath(req.path),
51
+ path: redactCalendarFeedPath(c.request.path),
62
52
  operation: c.operation?.operationId,
63
53
  }, "Validation failed");
64
54
  return {
@@ -74,71 +64,10 @@ api.register("notFound", () => ({
74
64
  statusCode: 404,
75
65
  body: { code: defaultErrorCode(404), message: "Not found" },
76
66
  }));
77
- api.register("notImplemented", async (c) => {
78
- const { status, mock } = api.mockResponseForOperation(c.operation.operationId);
79
- return formatResponse(mock, status);
80
- });
81
- api.register(handlers);
67
+ api.register("notImplemented", () => ({
68
+ statusCode: 501,
69
+ body: { code: defaultErrorCode(501), message: "Not implemented" },
70
+ }));
82
71
  api.init();
83
72
  export { setLogger } from "@remit/logger";
84
73
  export { api };
85
- const readOriginHeader = (headers) => {
86
- if (!headers)
87
- return undefined;
88
- for (const [key, value] of Object.entries(headers)) {
89
- if (key.toLowerCase() === "origin" && typeof value === "string") {
90
- return value;
91
- }
92
- }
93
- return undefined;
94
- };
95
- // A scope, not `logger.setBindings`: this process serves requests concurrently,
96
- // and bindings on the shared logger belong to whichever request wrote them last,
97
- // so a line gets attributed to the wrong request. The scope follows the request
98
- // through its own async continuations and nothing else. It nests inside the one
99
- // `withTelemetry` opens, which is where `requestId` comes from.
100
- export const handleApiEvent = async (event, context) => {
101
- // A calendar feed carries its credential in the path, so the raw path is a
102
- // secret and cannot be a log field (issue #1065). Redacted once, here, where
103
- // every line inside the request picks it up from the scope.
104
- const path = redactCalendarFeedPath(event.path);
105
- return withLogContext({
106
- path,
107
- method: event.httpMethod,
108
- }, async () => {
109
- logger.debug({ method: event.httpMethod, path }, "Request received");
110
- const origin = readOriginHeader(event.headers);
111
- const startedAt = Date.now();
112
- // Capture the correlation id here: it is seeded by
113
- // runWithRequestContext below (from context.awsRequestId), but that
114
- // AsyncLocalStorage scope does not reliably survive the
115
- // openapi-backend handleRequest call chain, so we read it from
116
- // `context.awsRequestId` directly rather than via
117
- // getRequestCorrelationId at log time.
118
- const correlationId = context.awsRequestId;
119
- const result = await runWithRequestContext({ origin, correlationId }, async () => {
120
- if (usesBetterAuthJwt()) {
121
- const denied = await authenticateSelfHostRequest(event);
122
- if (denied)
123
- return denied;
124
- }
125
- return api
126
- .handleRequest(normalizeRequest(event), event, context)
127
- .catch(handleError);
128
- });
129
- // One structured access line per request: method, path, status, and
130
- // duration. The correlation id is inlined as a top-level field so a bug
131
- // report quoting the id can grep logs by it directly, and emitted at
132
- // info so it is on by default in production (the debug "Request
133
- // received" above already filters out under normal log levels).
134
- // biome-ignore lint/plugin/no-logger-info: one access line per request is an audit-grade signal (issue #1054)
135
- logger.info({
136
- method: event.httpMethod,
137
- path,
138
- statusCode: result.statusCode,
139
- durationMs: Date.now() - startedAt,
140
- correlationId,
141
- }, "Request completed");
142
- return result;
143
- });
144
- };
@@ -1,4 +1,5 @@
1
1
  import type { APIGatewayProxyEvent } from "aws-lambda";
2
+ import type { HandlerIdentity } from "./types.js";
2
3
  /**
3
4
  * The LOCAL_ACCOUNT_CONFIG_ID / LOCAL_COGNITO_SUB overrides bypass authentication
4
5
  * for local dev and fixtures: with them set, an unauthenticated request is served
@@ -6,25 +7,18 @@ import type { APIGatewayProxyEvent } from "aws-lambda";
6
7
  * start (throw at module load) if either is present outside a local NODE_ENV — the
7
8
  * same `development`/`test` signal the data layer already uses to decide local vs
8
9
  * deployed. Fail loud rather than silently ignore, so a leaked env var is caught at
9
- * boot, not exploited at runtime.
10
+ * boot, not exploited at runtime. In a local NODE_ENV the bypass is read here
11
+ * once, so a bypass set without LOCAL_EMAIL also fails at boot.
10
12
  */
11
- export declare const assertLocalBypassNotInDeployedEnv: (env?: NodeJS.ProcessEnv) => void;
13
+ export declare const assertLocalBypassNotInDeployedEnv: (env: NodeJS.ProcessEnv) => void;
14
+ export declare const userFromClaims: (sub: string, email: string | undefined) => HandlerIdentity;
15
+ export declare const localIdentityFromEnv: (env: NodeJS.ProcessEnv) => HandlerIdentity;
12
16
  /**
13
- * Return the Cognito `sub` for the current request, preferring the JWT claim
14
- * and falling back to `LOCAL_COGNITO_SUB` for local development. Returns
15
- * `undefined` when neither is available (e.g. seeded fixtures that only set
16
- * `LOCAL_ACCOUNT_CONFIG_ID`).
17
+ * The caller of an API Gateway event. Production reads the Cognito `sub` and
18
+ * `email` claims (verified by the authorizer, or by `jwt-auth.ts` on
19
+ * self-host) and derives the accountConfigId via UUIDv5. Local development
20
+ * honours `LOCAL_ACCOUNT_CONFIG_ID` (a pinned seeded config) and
21
+ * `LOCAL_COGNITO_SUB` (a pinned sub, derived like production), read once at
22
+ * boot into `localIdentity`.
17
23
  */
18
- export declare const getSubFromEvent: (event: APIGatewayProxyEvent) => string | undefined;
19
- /**
20
- * Extract the caller's accountConfigId from an API Gateway event.
21
- *
22
- * Production: reads the Cognito `sub` claim (verified by the API Gateway
23
- * Cognito authorizer) and derives the accountConfigId via UUIDv5.
24
- *
25
- * Local development: honours two environment overrides so the dev server and
26
- * playwright fixtures can run without a real Cognito token:
27
- * - `LOCAL_ACCOUNT_CONFIG_ID` — pin a specific seeded accountConfigId
28
- * - `LOCAL_COGNITO_SUB` — pin a Cognito `sub` value; derived like production
29
- */
30
- export declare const getAccountConfigIdFromEvent: (event: APIGatewayProxyEvent) => string;
24
+ export declare const getIdentityFromEvent: (event: APIGatewayProxyEvent, localIdentity: HandlerIdentity) => HandlerIdentity;
package/dist/src/auth.js CHANGED
@@ -11,11 +11,14 @@ const isLocalEnv = (env) => env.NODE_ENV === "development" || env.NODE_ENV === "
11
11
  * start (throw at module load) if either is present outside a local NODE_ENV — the
12
12
  * same `development`/`test` signal the data layer already uses to decide local vs
13
13
  * deployed. Fail loud rather than silently ignore, so a leaked env var is caught at
14
- * boot, not exploited at runtime.
14
+ * boot, not exploited at runtime. In a local NODE_ENV the bypass is read here
15
+ * once, so a bypass set without LOCAL_EMAIL also fails at boot.
15
16
  */
16
- export const assertLocalBypassNotInDeployedEnv = (env = process.env) => {
17
- if (isLocalEnv(env))
17
+ export const assertLocalBypassNotInDeployedEnv = (env) => {
18
+ if (isLocalEnv(env)) {
19
+ localIdentityFromEnv(env);
18
20
  return;
21
+ }
19
22
  const leaked = LOCAL_BYPASS_VARS.filter((name) => {
20
23
  const value = env[name];
21
24
  return typeof value === "string" && value.length > 0;
@@ -24,50 +27,55 @@ export const assertLocalBypassNotInDeployedEnv = (env = process.env) => {
24
27
  return;
25
28
  throw new Error(`Refusing to start: local auth bypass ${leaked.join(", ")} is set while NODE_ENV=${env.NODE_ENV ?? "(unset)"}. These overrides bypass authentication and must never be present in a deployed environment.`);
26
29
  };
27
- const getSubFromClaims = (event) => {
28
- const claims = event.requestContext?.authorizer?.claims;
29
- if (!claims)
30
- return undefined;
31
- const sub = claims.sub;
32
- if (typeof sub === "string" && sub.length > 0)
33
- return sub;
30
+ const readClaim = (event, name) => {
31
+ const value = event.requestContext?.authorizer?.claims?.[name];
32
+ if (typeof value === "string" && value.length > 0)
33
+ return value;
34
34
  return undefined;
35
35
  };
36
- /**
37
- * Return the Cognito `sub` for the current request, preferring the JWT claim
38
- * and falling back to `LOCAL_COGNITO_SUB` for local development. Returns
39
- * `undefined` when neither is available (e.g. seeded fixtures that only set
40
- * `LOCAL_ACCOUNT_CONFIG_ID`).
41
- */
42
- export const getSubFromEvent = (event) => {
43
- const sub = getSubFromClaims(event);
44
- if (sub)
45
- return sub;
46
- const localSub = process.env.LOCAL_COGNITO_SUB;
47
- if (localSub && localSub.length > 0)
48
- return localSub;
36
+ const ANONYMOUS = { kind: "anonymous" };
37
+ export const userFromClaims = (sub, email) => {
38
+ if (!email)
39
+ return ANONYMOUS;
40
+ return {
41
+ kind: "user",
42
+ accountConfigId: deriveAccountConfigId(sub),
43
+ sub,
44
+ email,
45
+ };
46
+ };
47
+ const pinnedLocalUser = (env) => {
48
+ const localAccountConfigId = env.LOCAL_ACCOUNT_CONFIG_ID;
49
+ if (localAccountConfigId) {
50
+ return { accountConfigId: localAccountConfigId, sub: localAccountConfigId };
51
+ }
52
+ const localSub = env.LOCAL_COGNITO_SUB;
53
+ if (localSub) {
54
+ return { accountConfigId: deriveAccountConfigId(localSub), sub: localSub };
55
+ }
49
56
  return undefined;
50
57
  };
58
+ export const localIdentityFromEnv = (env) => {
59
+ const pinned = pinnedLocalUser(env);
60
+ if (!pinned)
61
+ return ANONYMOUS;
62
+ const email = env.LOCAL_EMAIL;
63
+ if (!email) {
64
+ throw new Error("Refusing to start: the local auth bypass (LOCAL_ACCOUNT_CONFIG_ID or LOCAL_COGNITO_SUB) is set without LOCAL_EMAIL. The bypass serves a signed-in user, and every user carries an email; set LOCAL_EMAIL to the address that user signs in with.");
65
+ }
66
+ return { kind: "user", ...pinned, email };
67
+ };
51
68
  /**
52
- * Extract the caller's accountConfigId from an API Gateway event.
53
- *
54
- * Production: reads the Cognito `sub` claim (verified by the API Gateway
55
- * Cognito authorizer) and derives the accountConfigId via UUIDv5.
56
- *
57
- * Local development: honours two environment overrides so the dev server and
58
- * playwright fixtures can run without a real Cognito token:
59
- * - `LOCAL_ACCOUNT_CONFIG_ID` — pin a specific seeded accountConfigId
60
- * - `LOCAL_COGNITO_SUB` — pin a Cognito `sub` value; derived like production
69
+ * The caller of an API Gateway event. Production reads the Cognito `sub` and
70
+ * `email` claims (verified by the authorizer, or by `jwt-auth.ts` on
71
+ * self-host) and derives the accountConfigId via UUIDv5. Local development
72
+ * honours `LOCAL_ACCOUNT_CONFIG_ID` (a pinned seeded config) and
73
+ * `LOCAL_COGNITO_SUB` (a pinned sub, derived like production), read once at
74
+ * boot into `localIdentity`.
61
75
  */
62
- export const getAccountConfigIdFromEvent = (event) => {
63
- const sub = getSubFromClaims(event);
64
- if (sub)
65
- return deriveAccountConfigId(sub);
66
- const localAccountConfigId = process.env.LOCAL_ACCOUNT_CONFIG_ID;
67
- if (localAccountConfigId)
68
- return localAccountConfigId;
69
- const localSub = process.env.LOCAL_COGNITO_SUB;
70
- if (localSub)
71
- return deriveAccountConfigId(localSub);
72
- throw new Error("Missing accountConfigId: no Cognito `sub` claim on the request, and neither LOCAL_ACCOUNT_CONFIG_ID nor LOCAL_COGNITO_SUB env var is set");
76
+ export const getIdentityFromEvent = (event, localIdentity) => {
77
+ const sub = readClaim(event, "sub");
78
+ if (!sub)
79
+ return localIdentity;
80
+ return userFromClaims(sub, readClaim(event, "email"));
73
81
  };
@@ -0,0 +1,20 @@
1
+ import type { MsOAuthConfig } from "./msoauth.js";
2
+ export type AuthConfig = {
3
+ mode: "BetterAuthJwt";
4
+ contentSigningSecret: string;
5
+ } | {
6
+ mode: "GatewayAuthorizer";
7
+ };
8
+ export type ResponseValidation = "Log" | "Skip";
9
+ export type SelfUpdate = "Enabled" | "Disabled";
10
+ export interface ApiConfig {
11
+ auth: AuthConfig;
12
+ corsAllowedOrigins: readonly string[];
13
+ publicOrigin: string;
14
+ contentDeliveryDomain: string;
15
+ msOAuth: MsOAuthConfig;
16
+ selfUpdate: SelfUpdate;
17
+ updateControlDir: string;
18
+ commit: string;
19
+ responseValidation: ResponseValidation;
20
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { ApiConfig } from "./api-config.js";
2
+ export declare const configFromEnv: (env: NodeJS.ProcessEnv) => ApiConfig;
@@ -0,0 +1,65 @@
1
+ import { msOAuthCredentialsFromSecret } from "./msoauth-secrets-manager.js";
2
+ const DEFAULT_UPDATE_CONTROL_DIR = "/data/control";
3
+ const optional = (env, key) => env[key] || undefined;
4
+ const required = (env, key, reason) => {
5
+ const value = optional(env, key);
6
+ if (!value)
7
+ throw new Error(`Refusing to start: ${key} is not set. ${reason}`);
8
+ return value;
9
+ };
10
+ const commaList = (raw) => (raw ?? "")
11
+ .split(",")
12
+ .map((origin) => origin.trim())
13
+ .filter((origin) => origin.length > 0);
14
+ const authFromEnv = (env) => env.DATA_BACKEND === "sqlite"
15
+ ? {
16
+ mode: "BetterAuthJwt",
17
+ contentSigningSecret: required(env, "BETTER_AUTH_SECRET", "The self-host SQL backend signs content URLs with it."),
18
+ }
19
+ : { mode: "GatewayAuthorizer" };
20
+ const contentDeliveryDomainFromEnv = (env, auth, publicOrigin) => {
21
+ if (auth.mode === "BetterAuthJwt") {
22
+ return optional(env, "CONTENT_DELIVERY_DOMAIN") ?? publicOrigin;
23
+ }
24
+ return required(env, "CONTENT_DELIVERY_DOMAIN", "BodyPartResponse.contentUrl is built from the CloudFront distribution domain (#299); wire it via the API Lambda env (see infra/bin/infra.ts).");
25
+ };
26
+ const msOAuthCredentialsFromEnv = (env) => {
27
+ const clientId = optional(env, "MSOAUTH_CLIENT_ID");
28
+ const clientSecret = optional(env, "MSOAUTH_CLIENT_SECRET");
29
+ if (clientId && clientSecret) {
30
+ return async () => ({ clientId, clientSecret });
31
+ }
32
+ const secretArn = optional(env, "MSOAUTH_SECRET_ARN");
33
+ if (secretArn)
34
+ return msOAuthCredentialsFromSecret(secretArn);
35
+ return undefined;
36
+ };
37
+ const msOAuthFromEnv = (env) => {
38
+ const credentials = msOAuthCredentialsFromEnv(env);
39
+ if (!credentials)
40
+ return { mode: "Disabled" };
41
+ const tokenEndpoint = optional(env, "MSOAUTH_TOKEN_ENDPOINT");
42
+ return {
43
+ mode: "Enabled",
44
+ redirectUri: required(env, "MSOAUTH_REDIRECT_URI", "It is derived from PUBLIC_ORIGIN — set PUBLIC_ORIGIN in the deploy env file (deploy/vps/.env) and restart, or set MSOAUTH_REDIRECT_URI directly."),
45
+ overrides: tokenEndpoint ? { tokenEndpoint } : {},
46
+ credentials,
47
+ };
48
+ };
49
+ export const configFromEnv = (env) => {
50
+ const publicOrigin = required(env, "PUBLIC_ORIGIN", "It is the origin the web client is served from.");
51
+ const auth = authFromEnv(env);
52
+ return {
53
+ auth,
54
+ corsAllowedOrigins: commaList(env.CORS_ALLOWED_ORIGINS),
55
+ publicOrigin,
56
+ contentDeliveryDomain: contentDeliveryDomainFromEnv(env, auth, publicOrigin),
57
+ msOAuth: msOAuthFromEnv(env),
58
+ selfUpdate: optional(env, "REMIT_UPDATE_MANIFEST_URL")
59
+ ? "Enabled"
60
+ : "Disabled",
61
+ updateControlDir: optional(env, "REMIT_UPDATE_CONTROL_DIR") ?? DEFAULT_UPDATE_CONTROL_DIR,
62
+ commit: optional(env, "REMIT_WORKER_SHA") ?? optional(env, "GITHUB_SHA") ?? "dev",
63
+ responseValidation: env.STAGE_NAME === "dev" ? "Log" : "Skip",
64
+ };
65
+ };
@@ -0,0 +1,2 @@
1
+ import type { MsOAuthCredentials } from "./msoauth.js";
2
+ export declare const msOAuthCredentialsFromSecret: (secretArn: string) => () => Promise<MsOAuthCredentials>;
@@ -0,0 +1,28 @@
1
+ import { GetSecretValueCommand, SecretsManagerClient, } from "@aws-sdk/client-secrets-manager";
2
+ import { z } from "zod";
3
+ const MsOAuthSecret = z.object({
4
+ clientId: z.string().min(1),
5
+ clientSecret: z.string().min(1),
6
+ });
7
+ let client = null;
8
+ const secretsManager = () => {
9
+ if (!client)
10
+ client = new SecretsManagerClient({});
11
+ return client;
12
+ };
13
+ const parseSecret = (raw) => {
14
+ try {
15
+ return JSON.parse(raw);
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ };
21
+ export const msOAuthCredentialsFromSecret = (secretArn) => async () => {
22
+ const result = await secretsManager().send(new GetSecretValueCommand({ SecretId: secretArn }));
23
+ const parsed = MsOAuthSecret.safeParse(parseSecret(result.SecretString ?? ""));
24
+ if (!parsed.success) {
25
+ throw new Error("The Microsoft OAuth secret named by MSOAUTH_SECRET_ARN does not hold a JSON object with a non-empty clientId and clientSecret");
26
+ }
27
+ return parsed.data;
28
+ };
@@ -1,30 +1,19 @@
1
- /**
2
- * Typed reads for Microsoft OAuth (Entra) environment variables.
3
- *
4
- * `MSOAUTH_REDIRECT_URI` is derived from `PUBLIC_ORIGIN` by the compose file
5
- * (deploy/vps/docker-compose.sqlite.yml) and must match the redirect URI
6
- * registered on the Entra app registration.
7
- *
8
- * Credentials come either from `MSOAUTH_CLIENT_ID` / `MSOAUTH_CLIENT_SECRET`
9
- * in the deploy env file, or from Secrets Manager via `MSOAUTH_SECRET_ARN`.
10
- *
11
- * The Microsoft authorization and token endpoints are fixed in
12
- * `@remit/mail-oauth-service`; no authority URL is read from the environment.
13
- */
14
- export interface MsOAuthConfig {
15
- /** Secrets Manager ARN — an alternative to the client id/secret pair. */
16
- readonly secretArn: string | undefined;
17
- /** OAuth redirect URI registered in the Entra app. */
18
- readonly redirectUri: string;
19
- /** Client ID — set directly to bypass Secrets Manager. */
20
- readonly clientId: string | undefined;
21
- /** Client secret — set directly to bypass Secrets Manager. */
22
- readonly clientSecret: string | undefined;
23
- /** Token endpoint override — used for local stubbing (bypasses default OIDC discovery). */
24
- readonly tokenEndpoint: string | undefined;
1
+ import type { OAuthProviderConfig } from "@remit/mail-oauth-service";
2
+ export interface MsOAuthCredentials {
3
+ clientId: string;
4
+ clientSecret: string;
25
5
  }
26
- /**
27
- * Read Microsoft OAuth configuration from the environment.
28
- * Throws when `MSOAUTH_REDIRECT_URI` is absent.
29
- */
30
- export declare const getMsOAuthConfig: () => MsOAuthConfig;
6
+ export interface MsOAuthEnabled {
7
+ mode: "Enabled";
8
+ redirectUri: string;
9
+ overrides: Partial<Pick<OAuthProviderConfig, "tokenEndpoint">>;
10
+ credentials: () => Promise<MsOAuthCredentials>;
11
+ }
12
+ export type MsOAuthConfig = {
13
+ mode: "Disabled";
14
+ } | MsOAuthEnabled;
15
+ export interface LoadedMsOAuth {
16
+ config: MsOAuthEnabled;
17
+ credentials: MsOAuthCredentials;
18
+ }
19
+ export declare const loadMsOAuth: (config: MsOAuthConfig) => Promise<LoadedMsOAuth>;
@@ -1,30 +1,6 @@
1
- /**
2
- * Typed reads for Microsoft OAuth (Entra) environment variables.
3
- *
4
- * `MSOAUTH_REDIRECT_URI` is derived from `PUBLIC_ORIGIN` by the compose file
5
- * (deploy/vps/docker-compose.sqlite.yml) and must match the redirect URI
6
- * registered on the Entra app registration.
7
- *
8
- * Credentials come either from `MSOAUTH_CLIENT_ID` / `MSOAUTH_CLIENT_SECRET`
9
- * in the deploy env file, or from Secrets Manager via `MSOAUTH_SECRET_ARN`.
10
- *
11
- * The Microsoft authorization and token endpoints are fixed in
12
- * `@remit/mail-oauth-service`; no authority URL is read from the environment.
13
- */
14
- /**
15
- * Read Microsoft OAuth configuration from the environment.
16
- * Throws when `MSOAUTH_REDIRECT_URI` is absent.
17
- */
18
- export const getMsOAuthConfig = () => {
19
- const redirectUri = process.env.MSOAUTH_REDIRECT_URI;
20
- if (!redirectUri) {
21
- throw new Error("MSOAUTH_REDIRECT_URI is not set. It is derived from PUBLIC_ORIGIN — set PUBLIC_ORIGIN in the deploy env file (deploy/vps/.env) and restart, or set MSOAUTH_REDIRECT_URI directly.");
1
+ export const loadMsOAuth = async (config) => {
2
+ if (config.mode === "Disabled") {
3
+ throw new Error("No Microsoft OAuth credentials are configured on this instance: set MSOAUTH_CLIENT_ID and MSOAUTH_CLIENT_SECRET, or MSOAUTH_SECRET_ARN");
22
4
  }
23
- return {
24
- secretArn: process.env.MSOAUTH_SECRET_ARN,
25
- redirectUri,
26
- clientId: process.env.MSOAUTH_CLIENT_ID,
27
- clientSecret: process.env.MSOAUTH_CLIENT_SECRET,
28
- tokenEndpoint: process.env.MSOAUTH_TOKEN_ENDPOINT,
29
- };
5
+ return { config, credentials: await config.credentials() };
30
6
  };
@@ -1,4 +1,5 @@
1
1
  import { type SignedPathFailure } from "@remit/storage-service/signed-path";
2
+ import type { ApiConfig } from "../config/api-config.js";
2
3
  /**
3
4
  * Default validity window for a signed content URL. Long enough to cover a
4
5
  * viewing session (react-query caches describeMessage for 30 min and inline
@@ -21,11 +22,9 @@ export declare const createContentSigner: (masterSecret: string, ttlSeconds?: nu
21
22
  * signing does not apply. Signing is enforced on the self-host SQL backend,
22
23
  * which serves `/content/*` straight from the backend container; on AWS the
23
24
  * Lambda@Edge JWT verifier guards `/content/*` and the URL stays unsigned so
24
- * CloudFront/S3 behaviour is unchanged. Throws on the self-host backend when the
25
- * master secret is missing, so a misconfigured deploy fails loud rather than
26
- * shipping unsigned (unauthenticated) content URLs.
25
+ * CloudFront/S3 behaviour is unchanged.
27
26
  */
28
- export declare const getContentSigner: () => ContentSigner | undefined;
27
+ export declare const getContentSigner: (config: ApiConfig) => ContentSigner | undefined;
29
28
  export type ContentSignatureFailure = SignedPathFailure;
30
29
  export type ContentSignatureResult = {
31
30
  valid: true;
@@ -1,5 +1,4 @@
1
1
  import { signStoragePath, verifyStoragePath, } from "@remit/storage-service/signed-path";
2
- import { usesBetterAuthJwt } from "../data-backend.js";
3
2
  /**
4
3
  * Signed-URL scheme for `/content/*` on the self-host stack.
5
4
  *
@@ -48,19 +47,11 @@ export const createContentSigner = (masterSecret, ttlSeconds = CONTENT_URL_TTL_S
48
47
  * signing does not apply. Signing is enforced on the self-host SQL backend,
49
48
  * which serves `/content/*` straight from the backend container; on AWS the
50
49
  * Lambda@Edge JWT verifier guards `/content/*` and the URL stays unsigned so
51
- * CloudFront/S3 behaviour is unchanged. Throws on the self-host backend when the
52
- * master secret is missing, so a misconfigured deploy fails loud rather than
53
- * shipping unsigned (unauthenticated) content URLs.
50
+ * CloudFront/S3 behaviour is unchanged.
54
51
  */
55
- export const getContentSigner = () => {
56
- if (!usesBetterAuthJwt())
57
- return undefined;
58
- const secret = process.env.BETTER_AUTH_SECRET;
59
- if (!secret || secret.length === 0) {
60
- throw new Error("the self-host SQL backend requires BETTER_AUTH_SECRET to sign content URLs");
61
- }
62
- return createContentSigner(secret);
63
- };
52
+ export const getContentSigner = (config) => config.auth.mode === "BetterAuthJwt"
53
+ ? createContentSigner(config.auth.contentSigningSecret)
54
+ : undefined;
64
55
  /**
65
56
  * Verify a signed content request. Recomputes the HMAC over the requested path
66
57
  * and the supplied expiry and constant-time compares it against the presented
@@ -31,16 +31,3 @@ export interface BuildStorageContentUrlInput {
31
31
  }
32
32
  export declare const buildStorageContentUrl: (input: BuildStorageContentUrlInput) => string;
33
33
  export declare const buildContentUrl: (input: BuildContentUrlInput) => string;
34
- /**
35
- * Read the CloudFront distribution domain from the environment. The CDK
36
- * wires `CONTENT_DELIVERY_DOMAIN` onto the API Lambda from the
37
- * ContentDelivery stack's published SSM parameter (`/{stage}/Remit/frontendUrl`).
38
- *
39
- * Throws when the env var is missing or empty. `BodyPartResponse.contentUrl`
40
- * is required + URL-formatted in the API contract, so silently emitting a
41
- * placeholder string would ship a contract lie to clients. Failing loud here
42
- * surfaces the missing wire-up as a 500 the deploy team can act on (#299).
43
- * Production synth hard-codes a syntactically-valid placeholder URL via
44
- * `infra/bin/infra.ts` so this only fires when the env is actually unset.
45
- */
46
- export declare const getContentDeliveryDomain: () => string;
@@ -25,22 +25,3 @@ export const buildContentUrl = (input) => {
25
25
  sign,
26
26
  });
27
27
  };
28
- /**
29
- * Read the CloudFront distribution domain from the environment. The CDK
30
- * wires `CONTENT_DELIVERY_DOMAIN` onto the API Lambda from the
31
- * ContentDelivery stack's published SSM parameter (`/{stage}/Remit/frontendUrl`).
32
- *
33
- * Throws when the env var is missing or empty. `BodyPartResponse.contentUrl`
34
- * is required + URL-formatted in the API contract, so silently emitting a
35
- * placeholder string would ship a contract lie to clients. Failing loud here
36
- * surfaces the missing wire-up as a 500 the deploy team can act on (#299).
37
- * Production synth hard-codes a syntactically-valid placeholder URL via
38
- * `infra/bin/infra.ts` so this only fires when the env is actually unset.
39
- */
40
- export const getContentDeliveryDomain = () => {
41
- const value = process.env.CONTENT_DELIVERY_DOMAIN;
42
- if (!value || value.length === 0) {
43
- throw new Error("CONTENT_DELIVERY_DOMAIN is not set; BodyPartResponse.contentUrl cannot be built. Wire the CloudFront distribution domain via the API Lambda env (see infra/bin/infra.ts).");
44
- }
45
- return value;
46
- };
@@ -1,5 +1,6 @@
1
1
  import type { AccountService as AccountServiceName } from "@remit/api-openapi-types";
2
- import type { MicrosoftOAuthOperationIds, OperationHandler } from "../types.js";
2
+ import type { Context } from "openapi-backend";
3
+ import type { HandlerContext, PublicOperationHandler } from "../types.js";
3
4
  export interface OAuthState {
4
5
  accountConfigId: string;
5
6
  nonce: string;
@@ -10,5 +11,10 @@ export declare const STATE_TTL_MS: number;
10
11
  export declare function signState(payload: OAuthState, clientSecret: string): Promise<string>;
11
12
  export declare function verifyState(state: string, clientSecret: string): Promise<OAuthState>;
12
13
  export declare function parseJwtClaims(token: string): Promise<Record<string, unknown> | null>;
13
- export declare function getWebOrigin(): string;
14
- export declare const MicrosoftOAuthOperations: Record<MicrosoftOAuthOperationIds, OperationHandler<MicrosoftOAuthOperationIds>>;
14
+ export declare function getWebOrigin(origins: readonly string[]): string;
15
+ export declare const microsoftOAuthCallback: PublicOperationHandler<"sync">;
16
+ export declare const MicrosoftOAuthOperations: {
17
+ MicrosoftOAuthOperations_microsoftOAuthStart: (context: Context, ctx: HandlerContext) => Promise<{
18
+ authorizationUrl: string;
19
+ }>;
20
+ };