make the boolean checks consistent, and guard against mistakes

This commit is contained in:
2026-09-14 11:43:22 -06:00
parent 885c6ced42
commit 93168ae360
5 changed files with 79 additions and 12 deletions
+1
View File
@@ -41,6 +41,7 @@
# one of the two "from" values are all present.
# TWILIO_ACCOUNT_SID=
# TWILIO_AUTH_TOKEN=
# Identifier for sender whom this is from.
# Prefer the Messaging Service once an A2P 10DLC campaign is approved: the
# campaign lives on it, and it wins when both are set.
# TWILIO_MESSAGING_SERVICE_SID=
+6 -1
View File
@@ -6,7 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.2.0] - 2026.09.05
## [Unreleased]
### Changed
- `SMS_ENABLED`, `SMS_REQUIRE_ACTION_CLAIM`, and `SMS_DEV_ECHO_CODE` parse like `DEBUG_ENDPOINT`: trimmed, case-insensitive, accepting `true`/`1`/`yes`/`on` and `false`/`0`/`no`/`off`. (A trailing space from `docker run --env-file` no longer silently turns SMS off, and an unrecognized `SMS_REQUIRE_ACTION_CLAIM` keeps the claim required instead of disabling it.)
## [0.2.1] - 2026.09.09
### Added
- `notifyHourUtc` (0-23) and `notifyMinuteUtc` (0-59) are **required** on an alert-authorization batch, both integers and both UTC — the field names carry the frame, so no offset or zone travels with them. Stored zero-padded as one `HH:MM` value in `notify_hour_min_utc`. Required rather than optional because the alternative default is not "no gate" but "the first tick after midnight UTC", which puts every user in one five-minute window. The scheduler holds a user's whole daily run, search included, until that instant, and reports the held users as `deferred`. A stored UTC time does not follow the user through a daylight-saving change; a fresh batch carrying the new offset corrects it
- Both alertSearch schedulers pick their users with one query per pass (`listPendingForDay`) instead of one per user: it returns everyone holding an unused JWT for the current UTC day, flagged by whether their `notify_hour_min_utc` has arrived. A user who has already run that day no longer appears, so an idle tick over 2000 users costs 0 queries and ~1ms, down from 3 queries per user and ~141ms
+3 -1
View File
@@ -748,13 +748,15 @@ Set `NOTIFY_DATA_DIR` to a durable directory, or keep the Docker default `/app/d
| `SMS_ACTION_JWT_MAX_AGE_SEC` | Optional | `300` |
| `SMS_MAX_DIDS_PER_PHONE` | Optional | `5` |
| `SMS_ALERT_SEARCH_INTERVAL_MS` | Optional | `300000` |
| `SMS_REQUIRE_ACTION_CLAIM` | Must **not** be `false` in production | `true` |
| `SMS_REQUIRE_ACTION_CLAIM` | Must **not** be a false value in production | `true` |
| `SMS_ALLOWED_RECIPIENT_DIDS` | Leave **unset** in production; on a test server, set it to the DIDs that server may text | Unset (no restriction) |
| `SMS_BLOCKED_NUMBERS` | Optional; numbers blocked by configuration, on top of the `sms_blocked_numbers` table | Empty |
| `SMS_DEV_ECHO_CODE` | Must be unset or `false`; honored only under `NODE_ENV=test-local` | `false` |
| Replicas | **One** process | Not enforced in code |
| Persistent volume | **Required** for Docker so SQLite survives replace | None unless you pass `-v` |
Boolean flags (`SMS_ENABLED`, `SMS_REQUIRE_ACTION_CLAIM`, `SMS_DEV_ECHO_CODE`, `DEBUG_ENDPOINT`) accept `true`/`1`/`yes`/`on` and `false`/`0`/`no`/`off`, case-insensitive, with surrounding whitespace ignored. Any other value, including a quoted one like `"true"` (which `docker run --env-file` passes through with its quotes), leaves the flag at its default.
Rotating `SMS_CODE_SECRET` invalidates every pending verification code and
orphans every stored `phone_hash`. Rotate between deploys, not casually.
+9 -10
View File
@@ -23,7 +23,12 @@ export const PARTNER_URL =
/** NODE_ENV value that unlocks developer conveniences. Never set in production. */
export const TEST_LOCAL_ENV = "test-local";
/** Truthy env values for boolean flags: "true"/"1"/"yes"/"on" (case-insensitive). */
/**
* 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();
@@ -35,12 +40,6 @@ function envFlag(value: string | undefined, fallback: boolean): boolean {
/** Mounts /debug when true. Off unless explicitly enabled. */
export const DEBUG_ENDPOINT = envFlag(process.env.DEBUG_ENDPOINT, false);
function booleanEnv(name: string, fallback: boolean): boolean {
const raw = process.env[name];
if (raw === undefined || raw.length === 0) return fallback;
return raw.toLowerCase() === "true" || raw === "1";
}
function intEnv(name: string, fallback: number): number {
const raw = process.env[name];
if (raw === undefined || raw.length === 0) return fallback;
@@ -103,7 +102,7 @@ export type SmsConfig = {
export function smsConfig(): SmsConfig {
const isTestLocal = process.env.NODE_ENV === TEST_LOCAL_ENV;
return {
enabled: booleanEnv("SMS_ENABLED", false),
enabled: envFlag(process.env.SMS_ENABLED, false),
codeSecret: stringEnv("SMS_CODE_SECRET"),
twilioAccountSid: stringEnv("TWILIO_ACCOUNT_SID"),
twilioAuthToken: stringEnv("TWILIO_AUTH_TOKEN"),
@@ -115,10 +114,10 @@ export function smsConfig(): SmsConfig {
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: booleanEnv("SMS_REQUIRE_ACTION_CLAIM", true),
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 && booleanEnv("SMS_DEV_ECHO_CODE", false),
devEchoCode: isTestLocal && envFlag(process.env.SMS_DEV_ECHO_CODE, false),
allowedRecipientDids: didListEnv("SMS_ALLOWED_RECIPIENT_DIDS"),
blockedNumbers: (process.env.SMS_BLOCKED_NUMBERS ?? "")
.split(",")
+60
View File
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import { afterEach, beforeEach, describe, it } from "node:test";
import { smsConfig } from "../src/env.js";
const KEYS = ["SMS_ENABLED", "SMS_REQUIRE_ACTION_CLAIM"] as const;
const saved: Record<string, string | undefined> = {};
beforeEach(() => {
for (const key of KEYS) {
saved[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of KEYS) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
});
describe("smsConfig boolean flags", () => {
it("uses the defaults when unset or empty", () => {
assert.equal(smsConfig().enabled, false);
assert.equal(smsConfig().requireActionClaim, true);
process.env.SMS_ENABLED = "";
process.env.SMS_REQUIRE_ACTION_CLAIM = "";
assert.equal(smsConfig().enabled, false);
assert.equal(smsConfig().requireActionClaim, true);
});
it("accepts every truthy spelling", () => {
for (const value of ["true", "TRUE", "1", "yes", "On"]) {
process.env.SMS_ENABLED = value;
assert.equal(smsConfig().enabled, true, value);
}
});
it("accepts every falsy spelling", () => {
for (const value of ["false", "FALSE", "0", "no", "Off"]) {
process.env.SMS_REQUIRE_ACTION_CLAIM = value;
assert.equal(smsConfig().requireActionClaim, false, value);
}
});
it("ignores surrounding whitespace, as docker --env-file leaves it", () => {
process.env.SMS_ENABLED = "true ";
assert.equal(smsConfig().enabled, true);
process.env.SMS_REQUIRE_ACTION_CLAIM = " false\t";
assert.equal(smsConfig().requireActionClaim, false);
});
it("falls back to the default on an unrecognized value", () => {
process.env.SMS_ENABLED = '"true"';
assert.equal(smsConfig().enabled, false);
process.env.SMS_REQUIRE_ACTION_CLAIM = "nope";
assert.equal(smsConfig().requireActionClaim, true);
});
});