Skip to content

feat(sms): guard every SMS send with destination validation, budgets and a circuit breaker - #305

Merged
roncodes merged 2 commits into
release/v1.6.69from
feature/sms-guard
Oct 13, 2026
Merged

roncodes merged 2 commits into
release/v1.6.69from
feature/sms-guard

Conversation

@roncodes

Copy link
Copy Markdown
Member

What

Every SMS the platform sends now passes a send guard before a provider is called, and for verification codes before the code row is written. This bounds what a leaked public storefront key (or any credential) can spend on SMS, and isolates an abused tenant from the rest.

Order of checks (Fleetbase\Support\Sms\SmsGuard::reserve()):

  1. policy switch and mode (enforce / monitor)
  2. destination validation with libphonenumber: must parse, be valid, and be a mobile (or fixed-or-mobile) number; premium-rate, shared-cost, satellite and admin-blocked ranges are refused
  3. platform / company / store pauses (circuit breaker)
  4. per-destination cooldown and hourly/daily caps, per purpose
  5. per-IP layer
  6. store/network and company budgets (default 200 a day per company; companies younger than 7 days get 20)
  7. platform-wide bucket (public and trusted separately)
  8. count the send; then the breaker reads the counters

Breaker: a company that uses up its daily budget, hits its hourly budget twice in a day, or texts 25+ distinct new numbers inside 10 minutes is paused for 60 minutes and the system admins are alerted (mail + in-app). Two automatic pauses in a day become a pause an admin has to lift. Trusted purposes (2fa, admin sends) skip tenant pauses and budgets so staff can still sign in.

Runtime control: limits, mode, pauses and alert recipients are a stored system.sms-guard setting (same pattern as API rate limits); config/sms.php only supplies defaults. sms:mode, sms:pause, sms:unpause change it without a deployment; an admin page can follow.

Refusals: SmsBlockedException with its own status: 422 (number can never be texted), 429 + Retry-After (cooldown/budget), 423 (paused), 503 (counters unreachable, public send). Existing senders already wrap SMS in try/catch, so email fallbacks keep working. The exception handler renders it in the usual error envelope.

Traceability: allowed sends record company, store/network, source, IP, user agent and destination country on the code row (meta.trace) and in sms.sent / sms.blocked / sms.would_block log entries.

Also: the onboarding resend route gets a per-address throttle in front of the guard; admin test sends and admin user verification are tagged as trusted purposes.

Compatibility

  • No API payload changes for successful sends.
  • Senders that bypass SmsService (notification channels) are not covered yet; that is a follow-up.
  • The counter store must be shared across workers (Redis in production); SMS_GUARD_CACHE_STORE can pin one. If it is unreachable, public sends fail closed and trusted/internal sends fail open.
  • Deploy with SMS_GUARD_MODE=monitor (or php artisan sms:mode monitor), watch sms.would_block, then php artisan sms:mode enforce.
  • Storefront and FleetOps PRs build on this: fleetbase/storefront feature/sms-guard, fleetbase/fleetops feature/sms-guard. They work with the current core; their validation and refusal statuses switch on once this ships.

Tests

Pest, no local run (CI verifies): PhoneValidatorTest, SmsPolicyTest, SmsGuardTest (ordering, cooldowns, budgets, breaker trips and escalation, monitor mode, degraded store), VerificationCodeGuardTest (no row on refusal, trace on success), SmsServiceGuardTest, SmsGuardCommandsTest. The shared test bootstrap keeps the guard off unless a test enables it.

Docs

Needs fleetbase.io docs for the new env vars and commands (needs-docs).

…and a circuit breaker

Every SMS now passes Fleetbase\Support\Sms\SmsGuard before a provider is called,
and for verification codes before the code row is written. The guard validates
the destination with libphonenumber (valid, mobile, not a premium or satellite
range), enforces a per-destination cooldown and caps, a per-IP layer, per-tenant
and per-company budgets and a platform-wide bucket, then counts the send. A
company that uses up its daily budget, hits its hourly budget twice, or texts a
burst of distinct new numbers is paused automatically and the system admins are
alerted (mail plus in-app). Trusted purposes (2FA, admin actions) skip tenant
pauses and budgets so staff can still sign in.

Limits, the enforce/monitor mode, pauses and alert recipients are a stored
system setting (config/sms.php supplies defaults), changeable at runtime with
the sms:mode, sms:pause and sms:unpause commands or a later admin page. A
refused send throws SmsBlockedException with its own status (422 bad number,
429 limited with Retry-After, 423 paused); existing email fallbacks keep working.
Allowed sends record who asked and from where on the code row (meta.trace) and
in sms.sent / sms.blocked log entries.

The onboarding resend route gets a per-address throttle in front of the guard.
Test bootstraps keep the guard off unless a test turns it on.
…ainer

The blocked-prefix and satellite fixtures no longer matched the numbers they
were meant to refuse, a bypassed reservation reported a trace, and the policy
test's users fixture mixed column sets. Eloquent queries on a container with
no connection resolver recurse until memory runs out, so the policy's company
and admin lookups now skip when there is no database (only unit tests).
@roncodes
roncodes merged commit 32c7023 into release/v1.6.69 Oct 13, 2026
2 of 3 checks passed
@roncodes
roncodes deleted the feature/sms-guard branch October 13, 2026 10:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant