139 lines
5.3 KiB
TypeScript
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.");
|
|
}
|
|
}
|