Repository navigation
feat(sms): guard every SMS send with destination validation, budgets and a circuit breaker - #305
Merged
Merged
Conversation
…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).
This was referenced Oct 13, 2026
This was referenced Oct 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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()):enforce/monitor)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-guardsetting (same pattern as API rate limits);config/sms.phponly supplies defaults.sms:mode,sms:pause,sms:unpausechange it without a deployment; an admin page can follow.Refusals:
SmsBlockedExceptionwith 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 insms.sent/sms.blocked/sms.would_blocklog 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
SmsService(notification channels) are not covered yet; that is a follow-up.SMS_GUARD_CACHE_STOREcan pin one. If it is unreachable, public sends fail closed and trusted/internal sends fail open.SMS_GUARD_MODE=monitor(orphp artisan sms:mode monitor), watchsms.would_block, thenphp artisan sms:mode enforce.feature/sms-guard, fleetbase/fleetopsfeature/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.iodocs for the new env vars and commands (needs-docs).