To make the migration as safe as possible, in places where it was
used to access environment variables that were absolutely required,
I replaced it with a function that forces the caller to explicitly
enumerate the environment variables they specifically expect,
allows them to access them in a type-safe way, and throws if the
named environment variables aren't set. (This is slightly different
than the previous behaviour, where AppConstants would only log the
missing variable without throwing, but I checked that all these
variables should be set in production.)
This commit is contained in:
Vincent 2024-09-11 15:32:55 +02:00 коммит произвёл Vincent
Родитель 3e6611491f
Коммит cda0f5a570
15 изменённых файлов: 115 добавлений и 136 удалений

Просмотреть файл

@ -6,8 +6,6 @@ src/db/**/*.js
src/emails/**/*.js
# TODO NEXT.JS MIGRATION:
# These files are remnants of our Express app:
src/appConstants.js
# These should be ignored anyway
coverage/

Просмотреть файл

@ -43,8 +43,6 @@ const customJestConfig = {
"<rootDir>/src/apiMocks/mockData.ts",
"<rootDir>/src/(.+).stories.(ts|tsx)",
"<rootDir>/.storybook/",
// Old, pre-Next.js code assumed to be working:
"<rootDir>/src/appConstants.js",
],
// Indicates which provider should be used to instrument code for coverage

Просмотреть файл

@ -47,13 +47,11 @@ afterEach(() => {
global.TextEncoder = TextEncoder;
// Jest doesn't like the top-level await in AppConstants, so we mock it. In
// time we can hopefully phase out the entire file and just use dotenv-flow
// and process.env directly.
jest.mock("./src/appConstants.js", () => {
// Jest doesn't like the top-level await in envVars.ts, so we mock it.
jest.mock("./src/envVars", () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
require("dotenv-flow").config();
return {
...process.env,
getEnvVarsOrThrow: () => process.env,
};
});

Просмотреть файл

@ -7,7 +7,6 @@ import { AuthOptions, Profile as FxaProfile, User } from "next-auth";
import { SubscriberRow } from "knex/types/tables";
import { logger } from "../../functions/server/logging";
import AppConstants from "../../../appConstants.js";
import {
getSubscriberByFxaUid,
updateFxAData,
@ -26,6 +25,15 @@ import { SerializedSubscriber } from "../../../next-auth";
import { record } from "../../functions/server/glean";
import { renderEmail } from "../../../emails/renderEmail";
import { SignupReportEmail } from "../../../emails/templates/signupReport/SignupReportEmail";
import { getEnvVarsOrThrow } from "../../../envVars";
const envVars = getEnvVarsOrThrow([
"OAUTH_AUTHORIZATION_URI",
"OAUTH_TOKEN_URI",
"OAUTH_CLIENT_ID",
"OAUTH_CLIENT_SECRET",
"OAUTH_PROFILE_URI",
]);
const fxaProviderConfig: OAuthConfig<FxaProfile> = {
// As per https://mozilla.slack.com/archives/C4D36CAJW/p1683642497940629?thread_ts=1683642325.465929&cid=C4D36CAJW,
@ -36,7 +44,7 @@ const fxaProviderConfig: OAuthConfig<FxaProfile> = {
name: "Mozilla accounts",
type: "oauth",
authorization: {
url: AppConstants.OAUTH_AUTHORIZATION_URI,
url: envVars.OAUTH_AUTHORIZATION_URI,
params: {
scope: "profile https://identity.mozilla.com/account/subscriptions",
access_type: "offline",
@ -45,11 +53,11 @@ const fxaProviderConfig: OAuthConfig<FxaProfile> = {
max_age: 0,
},
},
token: AppConstants.OAUTH_TOKEN_URI,
// userinfo: AppConstants.OAUTH_PROFILE_URI,
token: envVars.OAUTH_TOKEN_URI,
// userinfo: envVars.OAUTH_PROFILE_URI,
userinfo: {
request: async (context) => {
const response = await fetch(AppConstants.OAUTH_PROFILE_URI, {
const response = await fetch(envVars.OAUTH_PROFILE_URI, {
headers: {
Authorization: `Bearer ${context.tokens.access_token ?? ""}`,
},
@ -57,8 +65,8 @@ const fxaProviderConfig: OAuthConfig<FxaProfile> = {
return (await response.json()) as FxaProfile;
},
},
clientId: AppConstants.OAUTH_CLIENT_ID,
clientSecret: AppConstants.OAUTH_CLIENT_SECRET,
clientId: envVars.OAUTH_CLIENT_ID,
clientSecret: envVars.OAUTH_CLIENT_SECRET,
// Parse data returned by FxA's /userinfo/
profile: (profile) => {
return convertFxaProfile(profile);
@ -309,6 +317,6 @@ export function bearerToken(req: NextRequest) {
}
export function isAdmin(email: string) {
const admins = AppConstants.ADMINS?.split(",") ?? [];
const admins = (process.env.ADMINS ?? "").split(",") ?? [];
return admins.includes(email);
}

Просмотреть файл

@ -22,7 +22,6 @@ import {
} from "../../../functions/server/onerep";
import { bearerToken } from "../../utils/auth";
import { revokeOAuthTokens } from "../../../../utils/fxa";
import appConstants from "../../../../appConstants";
import { changeSubscription } from "../../../functions/server/changeSubscription";
import { deleteAccount } from "../../../functions/server/deleteAccount";
import { record } from "../../../functions/server/glean";
@ -43,7 +42,7 @@ const MONITOR_PREMIUM_CAPABILITY = "monitor";
* @returns {Promise<Array<jwt.JwtPayload> | undefined>} keys an array of FxA JWT keys
*/
const getJwtPubKey = async () => {
const jwtKeyUri = `${appConstants.OAUTH_ACCOUNT_URI}/jwks`;
const jwtKeyUri = `${process.env.OAUTH_ACCOUNT_URI}/jwks`;
try {
const response = await fetch(jwtKeyUri, {
headers: {

Просмотреть файл

@ -4,7 +4,6 @@
import { getToken } from "next-auth/jwt";
import { NextRequest, NextResponse } from "next/server";
import AppConstants from "../../../../../appConstants";
import { getSubscriberByFxaUid } from "../../../../../db/tables/subscribers";
import { addSubscriberUnverifiedEmailHash } from "../../../../../db/tables/emailAddresses";
@ -108,6 +107,6 @@ export async function POST(req: NextRequest) {
}
} else {
// Not Signed in, redirect to home
return NextResponse.redirect(AppConstants.SERVER_URL, 301);
return NextResponse.redirect(process.env.SERVER_URL ?? "/", 301);
}
}

Просмотреть файл

@ -6,7 +6,6 @@ import { getToken } from "next-auth/jwt";
import { NextRequest, NextResponse } from "next/server";
import { logger } from "../../../../functions/server/logging";
import AppConstants from "../../../../../appConstants";
import {
getSubscriberByFxaUid,
deleteResolutionsWithEmail,
@ -50,7 +49,7 @@ export async function POST(req: NextRequest) {
existingEmail.email,
);
return NextResponse.redirect(
AppConstants.SERVER_URL + "/user/settings",
process.env.SERVER_URL + "/user/settings",
301,
);
} catch (e) {

Просмотреть файл

@ -1,72 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
if (typeof process.env.NEXT_RUNTIME === "undefined" && typeof process.env.STORYBOOK === "undefined") {
// Next.js already loads env vars by itself, and dotenv-flow will throw an
// error if loaded in that context (about `fs` not existing), so only load
// it if we're not running in a Next.js-context (e.g. cron jobs):
await import("dotenv-flow/config");
}
// TODO: these vars were copy/pasted from the old app-constants.js and should be cleaned up
const requiredEnvVars = [
'ADMINS',
'APP_ENV',
'DATABASE_URL',
'DELETE_UNVERIFIED_SUBSCRIBERS_TIMER',
'EMAIL_FROM',
'HIBP_API_ROOT',
'HIBP_KANON_API_ROOT',
'HIBP_KANON_API_TOKEN',
'HIBP_NOTIFY_TOKEN',
'HIBP_THROTTLE_DELAY',
'HIBP_THROTTLE_MAX_TRIES',
'FXA_SETTINGS_URL',
'NODE_ENV',
'OAUTH_ACCOUNT_URI',
'OAUTH_AUTHORIZATION_URI',
'OAUTH_CLIENT_ID',
'OAUTH_CLIENT_SECRET',
'OAUTH_PROFILE_URI',
'OAUTH_TOKEN_URI',
'SERVER_URL',
'SES_CONFIG_SET',
'SMTP_URL',
'SUPPORTED_LOCALES'
]
const optionalEnvVars = [
'FX_REMOTE_SETTINGS_WRITER_PASS',
'FX_REMOTE_SETTINGS_WRITER_SERVER',
'FX_REMOTE_SETTINGS_WRITER_USER',
'HIBP_BREACH_DOMAIN_BLOCKLIST',
'PREMIUM_PRODUCT_ID',
'PG_HOST',
'NEXTAUTH_REDIRECT_URL'
]
/** @type {Record<string, string>} */
const AppConstants = { }
if (!process.env.SERVER_URL && (process.env.APP_ENV) === 'heroku') {
process.env.SERVER_URL = `https://${process.env.HEROKU_APP_NAME}.herokuapp.com`
}
for (const v of requiredEnvVars) {
const value = process.env[v]
if (value === undefined) {
console.warn(`Required environment variable was not set: ${v}`)
} else {
AppConstants[v] = value
}
}
optionalEnvVars.forEach(key => {
const value = process.env[key]
if (value) AppConstants[key] = value
})
export default AppConstants.NODE_ENV === 'test'
? AppConstants
: Object.freeze(AppConstants)

Просмотреть файл

@ -5,12 +5,14 @@
import type { Profile } from "next-auth";
import type { EmailAddressRow, SubscriberRow } from "knex/types/tables";
import createDbConnection from "../connect";
import AppConstants from "../../appConstants.js";
import { SerializedSubscriber } from "../../next-auth.js";
import { getFeatureFlagData } from "./featureFlags";
import { getEnvVarsOrThrow } from "../../envVars";
const knex = createDbConnection();
const { DELETE_UNVERIFIED_SUBSCRIBERS_TIMER } = AppConstants;
const { DELETE_UNVERIFIED_SUBSCRIBERS_TIMER } = getEnvVarsOrThrow([
"DELETE_UNVERIFIED_SUBSCRIBERS_TIMER",
]);
const MONITOR_PREMIUM_CAPABILITY = "monitor";
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy

29
src/envVars.ts Normal file
Просмотреть файл

@ -0,0 +1,29 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
if (
typeof process.env.NEXT_RUNTIME === "undefined" &&
typeof process.env.STORYBOOK === "undefined"
) {
// Next.js already loads env vars by itself, and dotenv-flow will throw an
// error if loaded in that context (about `fs` not existing), so only load
// it if we're not running in a Next.js-context (e.g. cron jobs):
await import("dotenv-flow/config");
}
export function getEnvVarsOrThrow<EnvVarNames extends string>(
envVars: EnvVarNames[],
): Record<EnvVarNames, string> {
const envVarsRecord: Record<EnvVarNames, string> = {} as never;
for (const varName of envVars) {
const value = process.env[varName];
if (typeof value !== "string") {
throw new Error(
`Required environment variable was not set: [${varName}].`,
);
}
envVarsRecord[varName] = value;
}
return envVarsRecord;
}

Просмотреть файл

@ -33,7 +33,6 @@
*
*/
import AppConstants from "../../appConstants";
import * as HIBP from "../../utils/hibp";
type RemoteSettingsBreach = Pick<
@ -41,11 +40,29 @@ type RemoteSettingsBreach = Pick<
"Name" | "Domain" | "BreachDate" | "PwnCount" | "AddedDate" | "DataClasses"
>;
const FX_REMOTE_SETTINGS_WRITER_USER =
process.env.FX_REMOTE_SETTINGS_WRITER_USER;
const FX_REMOTE_SETTINGS_WRITER_PASS =
process.env.FX_REMOTE_SETTINGS_WRITER_PASS;
const FX_REMOTE_SETTINGS_WRITER_SERVER =
process.env.FX_REMOTE_SETTINGS_WRITER_SERVER;
if (
!FX_REMOTE_SETTINGS_WRITER_USER ||
!FX_REMOTE_SETTINGS_WRITER_PASS ||
!FX_REMOTE_SETTINGS_WRITER_SERVER
) {
console.error(
"updatebreaches requires FX_REMOTE_SETTINGS_WRITER_SERVER, FX_REMOTE_SETTINGS_WRITER_USER, FX_REMOTE_SETTINGS_WRITER_PASS.",
);
process.exit(1);
}
const BREACHES_COLLECTION = "fxmonitor-breaches";
const FX_RS_COLLECTION = `${AppConstants.FX_REMOTE_SETTINGS_WRITER_SERVER}/buckets/main-workspace/collections/${BREACHES_COLLECTION}`;
const FX_RS_COLLECTION = `${FX_REMOTE_SETTINGS_WRITER_SERVER}/buckets/main-workspace/collections/${BREACHES_COLLECTION}`;
const FX_RS_RECORDS = `${FX_RS_COLLECTION}/records`;
const FX_RS_WRITER_USER = AppConstants.FX_REMOTE_SETTINGS_WRITER_USER;
const FX_RS_WRITER_PASS = AppConstants.FX_REMOTE_SETTINGS_WRITER_PASS;
const FX_RS_WRITER_USER = FX_REMOTE_SETTINGS_WRITER_USER;
const FX_RS_WRITER_PASS = FX_REMOTE_SETTINGS_WRITER_PASS;
async function whichBreachesAreNotInRemoteSettingsYet(
breaches: HIBP.HibpGetBreachesResponse,
@ -90,17 +107,6 @@ async function requestReviewOnBreachesCollection() {
return response.json();
}
if (
!AppConstants.FX_REMOTE_SETTINGS_WRITER_USER ||
!AppConstants.FX_REMOTE_SETTINGS_WRITER_PASS ||
!AppConstants.FX_REMOTE_SETTINGS_WRITER_SERVER
) {
console.error(
"updatebreaches requires FX_REMOTE_SETTINGS_WRITER_SERVER, FX_REMOTE_SETTINGS_WRITER_USER, FX_REMOTE_SETTINGS_WRITER_PASS.",
);
process.exit(1);
}
(async () => {
const allHibpBreaches = await HIBP.fetchHibpBreaches();
const verifiedSiteBreaches = allHibpBreaches.filter((breach) => {

Просмотреть файл

@ -6,7 +6,6 @@
import fs from "fs";
import path from "path";
import AppConstants from "../appConstants";
import packageJson from "../../package.json";
export type VersionData = {
@ -23,7 +22,7 @@ if (!fs.existsSync(versionJsonPath)) {
const versionJson = {
source: packageJson.homepage,
version: packageJson.version,
NODE_ENV: AppConstants.NODE_ENV,
NODE_ENV: process.env.NODE_ENV,
};
fs.writeFileSync(
@ -33,7 +32,7 @@ if (!fs.existsSync(versionJsonPath)) {
}
export function vers(): VersionData {
if (AppConstants.APP_ENV === "heroku") {
if (process.env.APP_ENV === "heroku") {
/* eslint-disable no-process-env */
return {
commit: process.env.HEROKU_SLUG_COMMIT!,

Просмотреть файл

@ -4,14 +4,16 @@
import { createTransport, Transporter } from "nodemailer";
import AppConstants from "../appConstants.js";
import { SentMessageInfo } from "nodemailer/lib/smtp-transport/index.js";
import { getEnvVarsOrThrow } from "../envVars";
// The SMTP transport object. This is initialized to a nodemailer transport
// object while reading SMTP credentials, or to a dummy function in debug mode.
let gTransporter: Transporter<SentMessageInfo>;
async function initEmail(smtpUrl = AppConstants.SMTP_URL) {
const envVars = getEnvVarsOrThrow(["SMTP_URL", "EMAIL_FROM", "SES_CONFIG_SET"]);
async function initEmail(smtpUrl = envVars.SMTP_URL) {
// Allow a debug mode that will log JSON instead of sending emails.
if (!smtpUrl) {
console.info("smtpUrl-empty", {
@ -42,14 +44,14 @@ async function sendEmail(
throw new Error("SMTP transport not initialized");
}
const emailFrom = AppConstants.EMAIL_FROM;
const emailFrom = envVars.EMAIL_FROM;
const mailOptions = {
from: emailFrom,
to: recipient,
subject,
html,
headers: {
"x-ses-configuration-set": AppConstants.SES_CONFIG_SET,
"x-ses-configuration-set": envVars.SES_CONFIG_SET,
},
};

Просмотреть файл

@ -5,7 +5,13 @@
import crypto from "crypto";
import { URL } from "url";
import AppConstants from "../appConstants.js";
import { getEnvVarsOrThrow } from "../envVars";
const envVars = getEnvVarsOrThrow([
"OAUTH_CLIENT_ID",
"OAUTH_CLIENT_SECRET",
"OAUTH_TOKEN_URI",
"OAUTH_ACCOUNT_URI",
]);
/**
* @see https://mozilla.github.io/ecosystem-platform/api#tag/Oauth/operation/postOauthDestroy
@ -30,11 +36,11 @@ async function destroyOAuthToken(
) {
const tokenBody: FxaPostOauthDestroyRequestBody = {
...tokenData,
client_id: AppConstants.OAUTH_CLIENT_ID,
client_secret: AppConstants.OAUTH_CLIENT_SECRET,
client_id: envVars.OAUTH_CLIENT_ID,
client_secret: envVars.OAUTH_CLIENT_SECRET,
};
const fxaTokenOrigin = new URL(AppConstants.OAUTH_TOKEN_URI).origin;
const fxaTokenOrigin = new URL(envVars.OAUTH_TOKEN_URI).origin;
const tokenUrl = `${fxaTokenOrigin}/v1/oauth/destroy`;
const tokenOptions = {
method: "POST",
@ -132,10 +138,10 @@ type FxaPostOauthTokenResponseSuccessRefreshToken = {
async function refreshOAuthTokens(
refreshToken: string,
): Promise<FxaPostOauthTokenResponseSuccessRefreshToken> {
const subscriptionIdUrl = `${AppConstants.OAUTH_ACCOUNT_URI}/oauth/token`;
const subscriptionIdUrl = `${envVars.OAUTH_ACCOUNT_URI}/oauth/token`;
const body: FxaPostOauthTokenRequestBody = {
client_id: AppConstants.OAUTH_CLIENT_ID,
client_secret: AppConstants.OAUTH_CLIENT_SECRET,
client_id: envVars.OAUTH_CLIENT_ID,
client_secret: envVars.OAUTH_CLIENT_SECRET,
grant_type: "refresh_token",
refresh_token: refreshToken,
ttl: 604800, // request 7 days ttl
@ -175,7 +181,7 @@ type FxaGetOauthSubscribptionsActiveResponseSuccess = Array<{
async function getSubscriptions(
bearerToken: string,
): Promise<FxaGetOauthSubscribptionsActiveResponseSuccess | null> {
const subscriptionIdUrl = `${AppConstants.OAUTH_ACCOUNT_URI}/oauth/subscriptions/active`;
const subscriptionIdUrl = `${envVars.OAUTH_ACCOUNT_URI}/oauth/subscriptions/active`;
try {
const response = await fetch(subscriptionIdUrl, {
headers: {
@ -221,7 +227,7 @@ type FxaGetOauthMozillaSubscribptionsCustomerBillingAndSubscriptionsResponseSucc
async function getBillingAndSubscriptions(
bearerToken: string,
): Promise<FxaGetOauthMozillaSubscribptionsCustomerBillingAndSubscriptionsResponseSuccess | null> {
const subscriptionIdUrl = `${AppConstants.OAUTH_ACCOUNT_URI}/oauth/mozilla-subscriptions/customer/billing-and-subscriptions`;
const subscriptionIdUrl = `${envVars.OAUTH_ACCOUNT_URI}/oauth/mozilla-subscriptions/customer/billing-and-subscriptions`;
try {
const response = await fetch(subscriptionIdUrl, {
@ -253,13 +259,13 @@ async function deleteSubscription(bearerToken: string): Promise<boolean> {
if (
sub &&
sub.productId &&
sub.productId === AppConstants.PREMIUM_PRODUCT_ID
sub.productId === process.env.PREMIUM_PRODUCT_ID
) {
subscriptionId = sub.subscriptionId;
}
}
if (subscriptionId) {
const deleteUrl = `${AppConstants.OAUTH_ACCOUNT_URI}/oauth/subscriptions/active/${subscriptionId}`;
const deleteUrl = `${envVars.OAUTH_ACCOUNT_URI}/oauth/subscriptions/active/${subscriptionId}`;
const response = await fetch(deleteUrl, {
method: "DELETE",
headers: {
@ -294,13 +300,13 @@ async function applyCoupon(
if (
sub &&
sub.productId &&
sub.productId === AppConstants.PREMIUM_PRODUCT_ID
sub.productId === process.env.PREMIUM_PRODUCT_ID
) {
subscriptionId = sub.subscriptionId;
}
}
if (subscriptionId) {
const applyCouponUrl = `${AppConstants.OAUTH_ACCOUNT_URI}/oauth/subscriptions/coupon/apply`;
const applyCouponUrl = `${envVars.OAUTH_ACCOUNT_URI}/oauth/subscriptions/coupon/apply`;
const response = await fetch(applyCouponUrl, {
method: "PUT",
headers: {

Просмотреть файл

@ -2,7 +2,6 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import AppConstants from "../appConstants.js";
import { getAllBreaches, knex } from "../db/tables/breaches";
import { isUsingMockHIBPEndpoint } from "../app/functions/universal/mock.ts";
import { BreachRow, EmailAddressRow, SubscriberRow } from "knex/types/tables";
@ -14,13 +13,20 @@ import {
getQaToggleRow,
} from "../db/tables/qa_customs.ts";
import { redisClient, REDIS_ALL_BREACHES_KEY } from "../db/redis/client.ts";
import { getEnvVarsOrThrow } from "../envVars.ts";
const {
HIBP_THROTTLE_MAX_TRIES,
HIBP_THROTTLE_DELAY,
HIBP_API_ROOT,
HIBP_KANON_API_ROOT,
HIBP_KANON_API_TOKEN,
} = AppConstants;
} = getEnvVarsOrThrow([
"HIBP_THROTTLE_MAX_TRIES",
"HIBP_THROTTLE_DELAY",
"HIBP_API_ROOT",
"HIBP_KANON_API_ROOT",
"HIBP_KANON_API_TOKEN",
]);
// TODO: fix hardcode
const HIBP_USER_AGENT = "monitor/1.0.0";
@ -73,8 +79,10 @@ async function _throttledFetch(
} else {
tryCount++;
await new Promise((resolve) =>
// @ts-ignore HIBP_THROTTLE_DELAY should be defined
setTimeout(resolve, HIBP_THROTTLE_DELAY * tryCount),
setTimeout(
resolve,
Number.parseInt(HIBP_THROTTLE_DELAY, 10) * tryCount,
),
);
return await _throttledFetch(url, reqOptions, tryCount);
}