Files
notification-wakeup-service/src/env.ts
T

139 lines
5.3 KiB
TypeScript

import { config } from "dotenv";
config();
/** Production Endorser host (same default as the app DEFAULT_ENDORSER_API_SERVER). */
export const DEFAULT_ENDORSER_API_SERVER = "https://api.endorser.ch";
/** Production Partner host (same default as the app DEFAULT_PARTNER_API_SERVER). */
export const DEFAULT_PARTNER_API_SERVER = "https://partner-api.endorser.ch";
/** Base URL for the Endorser API server. */
export const ENDORSER_URL =
process.env.ENDORSER_URL ??
process.env.DEFAULT_ENDORSER_API_SERVER ??
DEFAULT_ENDORSER_API_SERVER;
/** Base URL for the Partner API server. Separate from Endorser. */
export const PARTNER_URL =
process.env.PARTNER_URL ??
process.env.DEFAULT_PARTNER_API_SERVER ??
DEFAULT_PARTNER_API_SERVER;
/** NODE_ENV value that unlocks developer conveniences. Never set in production. */
export const TEST_LOCAL_ENV = "test-local";
/**
* Every boolean env flag goes through here. Accepts "true"/"1"/"yes"/"on" and
* "false"/"0"/"no"/"off", case-insensitive and trimmed, because `docker run
* --env-file` keeps trailing whitespace. Anything else yields the fallback, so
* a typo leaves a flag at its documented default rather than flipping it.
*/
function envFlag(value: string | undefined, fallback: boolean): boolean {
if (value === undefined || value.trim().length === 0) return fallback;
const normalized = value.trim().toLowerCase();
if (["true", "1", "yes", "on"].includes(normalized)) return true;
if (["false", "0", "no", "off"].includes(normalized)) return false;
return fallback;
}
/** Mounts /debug when true. Off unless explicitly enabled. */
export const DEBUG_ENDPOINT = envFlag(process.env.DEBUG_ENDPOINT, false);
function intEnv(name: string, fallback: number): number {
const raw = process.env[name];
if (raw === undefined || raw.length === 0) return fallback;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
/**
* A DID allowlist, or undefined when the variable is absent entirely. Present
* but empty yields an empty list, which blocks every recipient: an operator who
* sets the variable meant to restrict something, so the blank case fails closed.
*/
function didListEnv(name: string): string[] | undefined {
const raw = process.env[name];
if (raw === undefined) return undefined;
return raw
.split(",")
.map((did) => did.trim().toLowerCase())
.filter((did) => did.length > 0);
}
function stringEnv(name: string): string | undefined {
const raw = process.env[name];
return raw !== undefined && raw.length > 0 ? raw : undefined;
}
export type SmsConfig = {
enabled: boolean;
codeSecret?: string;
twilioAccountSid?: string;
twilioAuthToken?: string;
twilioFromNumber?: string;
twilioMessagingServiceSid?: string;
/** The public URL Twilio posts the inbound webhook to; it signs that exact string. */
twilioWebhookUrl?: string;
codeTtlSec: number;
codeMaxAttempts: number;
actionJwtMaxAgeSec: number;
maxDidsPerPhone: number;
alertSearchIntervalMs: number;
requireActionClaim: boolean;
/** Echo the verification code in the POST response. Both conditions required. */
devEchoCode: boolean;
/**
* When present, the only DIDs this instance may text. Undefined means no
* restriction, which is what production runs with.
*/
allowedRecipientDids?: string[];
/**
* Numbers blocked by configuration, on top of whatever the database holds.
* Normalized at read time so the comparison matches stored E.164.
*/
blockedNumbers: string[];
};
/**
* Read on each call rather than frozen at import, so a process that has its
* environment adjusted (and every test) sees the value it just set.
*/
export function smsConfig(): SmsConfig {
const isTestLocal = process.env.NODE_ENV === TEST_LOCAL_ENV;
return {
enabled: envFlag(process.env.SMS_ENABLED, false),
codeSecret: stringEnv("SMS_CODE_SECRET"),
twilioAccountSid: stringEnv("TWILIO_ACCOUNT_SID"),
twilioAuthToken: stringEnv("TWILIO_AUTH_TOKEN"),
twilioFromNumber: stringEnv("TWILIO_FROM_NUMBER"),
twilioMessagingServiceSid: stringEnv("TWILIO_MESSAGING_SERVICE_SID"),
twilioWebhookUrl: stringEnv("TWILIO_WEBHOOK_URL"),
codeTtlSec: intEnv("SMS_CODE_TTL_SEC", 600),
codeMaxAttempts: intEnv("SMS_CODE_MAX_ATTEMPTS", 5),
actionJwtMaxAgeSec: intEnv("SMS_ACTION_JWT_MAX_AGE_SEC", 300),
maxDidsPerPhone: intEnv("SMS_MAX_DIDS_PER_PHONE", 5),
alertSearchIntervalMs: intEnv("SMS_ALERT_SEARCH_INTERVAL_MS", 300000),
requireActionClaim: envFlag(process.env.SMS_REQUIRE_ACTION_CLAIM, true),
// NODE_ENV is checked first: a production process with the flag set by
// accident echoes nothing, because its NODE_ENV is not test-local.
devEchoCode: isTestLocal && envFlag(process.env.SMS_DEV_ECHO_CODE, false),
allowedRecipientDids: didListEnv("SMS_ALLOWED_RECIPIENT_DIDS"),
blockedNumbers: (process.env.SMS_BLOCKED_NUMBERS ?? "")
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0),
};
}
/**
* The code secret keys every pending code and every phone hash. Running enabled
* without it would store codes no PUT could ever match.
*/
export function assertSmsConfigured(config: SmsConfig = smsConfig()): void {
if (!config.enabled) return;
if (config.codeSecret === undefined) {
throw new Error("SMS_ENABLED is set but SMS_CODE_SECRET is missing.");
}
}