diff --git a/RELEASE.md b/RELEASE.md index d6d11976..4a33fccb 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,51 +1,10 @@ -# v1.6.69 — SMS send guard, authenticated realtime channels, API cache invalidation, comment visibility and database backups - -## Added - -- **SMS send guard.** Every SMS passes `Fleetbase\Support\Sms\SmsGuard` before a provider is called, and before a verification code row is written: destination validation with libphonenumber (mobile numbers only; premium-rate, shared-cost, satellite and blocked ranges are refused), a per-destination cooldown and caps, a per-IP layer, store/network and company budgets (200 a day per company by default, 20 for companies younger than 7 days), a platform-wide bucket, and a circuit breaker that pauses a tenant and alerts the system administrators when it uses up its daily budget, hits its hourly budget twice in a day, texts a burst of distinct new numbers, texts a country it has never texted, or issues codes that nobody verifies. Trusted purposes (2FA, admin sends) skip tenant pauses and budgets so staff can still sign in. Refusals are `SmsBlockedException`, rendered in the usual error envelope with 422 (number can never be texted), 429 with `Retry-After` (cooldown or budget), 423 (paused) or 503 (counters unreachable). Limits, mode, pauses and alert recipients live in the `system.sms-guard` setting; `config/sms.php` supplies the defaults, and `sms:mode`, `sms:pause` and `sms:unpause` change them without a deployment. (#305, #307) -- **SMS send ledger.** Every allowed send is reserved in `sms_sends` and settled with the provider outcome, and every refusal is written with its reason, with tenant, purpose, source, client address, destination country and type. `sms:ledger:prune` masks numbers after 30 days and deletes rows after 90. Writes are best effort: a missing table never fails a send. (#307) -- **SMS guard admin API** under `int/v1/sms-guard`: settings (mode, budgets, blocklists, alert recipients, per-company exceptions), reset, pauses (create and lift), usage per organization against its budget, and the ledger with filters. The console page ships in fleetbase/fleetbase v0.7.69. (#307) -- **`SmsServiceChannel`**, a notification channel that delivers through `SmsService`, so notification texts are guarded and ledgered too. (#307) -- **Verification code tracing and single-use hashed codes.** `verification_codes` gains `company_uuid`, `identity`, `channel`, `ip_address`, `user_agent`, `sms_send_uuid` and `consumed_at`. `generateSmsVerificationFor(..., ['hashed' => true])` stores an HMAC and hands the message callback the plain code; `findActiveFor()` and `findActiveForSubject()` return the newest live code, `check()` verifies it and `consume()` uses it up. `VerificationCode::issue()` stores an HMAC and returns the plain code once, and `check()` counts attempts and reports `valid`, `invalid`, `expired` or `locked`. The existing generators are unchanged. (#289, #307) -- **Authenticated realtime channels.** Socket tokens, a channel authorizer with per-resource resolvers, and signed HTTP publish. `POST int/v1/socket/token` mints a user token, `POST v1/socket/system-token` serves platform API callers, and `POST int/v1/socket/authorize` is called only by the socket server, with signed requests. Extensions register channel resolvers for their own resources. It is **off by default** and stays off until `SOCKETCLUSTER_AUTH_ENABLED=true`, even with a key set, so existing socket clients (mobile apps, the console, integrations) keep working. The publisher now sends a configurable `Origin` (`SOCKETCLUSTER_ORIGIN`) on its handshake, so a socket server with restricted origins accepts server broadcasts. (#290) -- **Comment visibility and `comment.created`.** Comments carry `meta.visibility` (`internal` or `customer`), accepted as `visibility` on create. A reply takes its parent's visibility; a comment on an order is `internal` from the console and `customer` from anywhere else; comments on other subjects keep no visibility and stay visible. The public v1 comments API excludes internal comments, and the comment resource gains a top-level `visibility`. A new `comment.created` broadcast goes to `company.{uuid}` for every comment, and customer-visible comments also go to the subject's own channels (`order.order_x`). (#306) -- **Chat participant roles and system messages.** `chat_participants` and `chat_messages` gain a `meta` column; participants expose `role` (`customer`, `driver`, `store`, `system`) and messages expose `system` and `meta` (for example `kind: order_updated`). (#306) -- **API cache dependencies.** Cache keys carry the versions of every table they embed, so a write to a related table invalidates the lists, records and relationships that embed it. Dependencies come from `protected $apiCacheDependencies` on the model, the request's `with`, `expand` and counts, and the lookups' own paths. `withoutInvalidating()` scopes coalesced high-frequency writes, and `API_CACHE_STRICT_DEPENDENCIES` makes a bad declaration throw in test suites. (#301) -- **Database backups.** Settings-driven backups (`db:backup`) on a configurable schedule, with environment defaults in `config/database-backups.php` (`DB_BACKUP_*`) and an admin API under `int/v1/database-backups`. Failures email the configured addresses and are logged. (#288, #295, #296) -- `LogApiRequest::RELATED_ATTRIBUTE` (`api_request_log_related`): an extension sets it on the request and the ids land in the log row's `related` column, so a storefront request traces back to its store or network key. (#302) +# v1.6.70 — Text a verification code to the number being verified ## Changed -- **API cache invalidation.** Invalidation now changes the keys of every cache: lists moved on the first write only after a second write, and single records and relationships relied on a tag flush that leaves values behind on phpredis 6.1 and later. Versioned keys are the invalidation mechanism, list keys cover the request path and the session scope (`api.cache.scope.session`), writes inside a transaction bump again on commit, and an invalidation failure never fails the write that triggered it. On deploy every key changes once. (#299, #300, #301) -- **API request logs.** Any route that attaches `LogApiRequests` is logged, not only routes under the core `v1/` prefix, so storefront API writes are traceable. Internal routes are still skipped. (#302) -- **`without[]`** uses `makeHidden()`, so it no longer un-hides columns the model hides. Requested relation names come back as declared, so `subfleets` loads as `subFleets` and renders. `withCounts()` accepts an array. (#303) -- **Private media buckets.** Stored file URLs that point into the configured `s3` bucket are signed again on read, so the bucket can be fully private. (#287) -- **Faster lookups.** The country lookup is cached, and `files.subject_uuid` is indexed. (#291) -- The admin SocketCluster test always publishes to `test.{current user uuid}` and returns the channel it used. (#290) -- The manual database backup is dispatched through the bus, and `DB_BACKUP_BUCKET` no longer has a hard-coded default: with no bucket set, the `s3` disk's own bucket is used. (#295, #296) - -## Security - -- **Requested relations must be relations.** `with`, `expand`, `with_count` and `count` reached Eloquent unchecked, so a request could run any public method with no required arguments on a model (`with[]=dispatch` dispatched a blank order). A name is now accepted only when the method declares an Eloquent `Relation` return type (plain or union), is an `Expandable` closure that does, or is listed in the model's `protected $withable`. Nested paths are checked segment by segment and cut at the first that fails; rejected names are dropped without an error. (#303) -- **Only registered notifiables are resolved** from saved notification settings. A stored `dynamic:` name or class that no extension registered through `NotificationRegistry::registerNotifiable()` is skipped instead of being called or built. (#304) -- **Deletes are scoped to the company.** `deleteRecord` compared a qualified column name against the table's columns and never matched, so the company constraint was skipped. Reports are filtered by company through the new `ReportFilter`. -- **A failed comment publish no longer leaves every model unguarded** in a long-lived Octane worker. (#298) -- Order lifecycle events reach `driverAssigned.{driver uuid}` again; they went to a dead `driverAssigned.` channel. (#306) - -## Removed - -- `MysqlS3Backup`, `S3BackupTrimmer` and `config/laravel-mysql-s3-backup.php`, replaced by the new database backups. (#288) -- The unrouted legacy Twilio-direct SMS login methods on `AuthController`. (#307) +- **`VerificationCode::generateSmsVerificationFor()` takes a `phone` option.** The code is texted to that number instead of the subject's own, and the SMS guard counts and traces the send against it. A customer changing their phone number now gets the code on the new number, including a customer with no number on file, who previously got nothing while the request reported success. Without the option nothing changes: the subject's phone is used, and a subject with no phone still gets a saved code and no text. ## Upgrade Steps -- Run migrations: `database_backups` (#288), the `files.subject_uuid` index (#291), `meta` on `chat_participants` and `chat_messages` (#306), `sms_sends` and the `verification_codes` tracing columns (#307). Existing comments and codes are untouched. -- The SMS guard is **on** (`SMS_GUARD_ENABLED`, default `true`) in `enforce` mode (`SMS_GUARD_MODE`). To watch before refusing, deploy with `SMS_GUARD_MODE=monitor` or run `php artisan sms:mode monitor`, review the `sms.would_block` log entries, then `php artisan sms:mode enforce`. The counters must live in a store shared by every worker (Redis in production); `SMS_GUARD_CACHE_STORE` pins one. Set `SMS_GUARD_ALERT_EMAILS` for alerts beyond the system administrators, and `SMS_GUARD_BLOCKED_COUNTRIES` / `SMS_GUARD_BLOCKED_PREFIXES` as needed. Schedule `php artisan sms:ledger:prune` daily (`SMS_GUARD_LEDGER_RETENTION_DAYS`, `SMS_GUARD_LEDGER_MASK_AFTER_DAYS`). -- Socket auth (#290) adds `SOCKETCLUSTER_AUTH_ENABLED` (default `false`), `SOCKETCLUSTER_AUTH_KEY`, `SOCKETCLUSTER_PUBLISH_URL` (default `http://{SOCKETCLUSTER_HOST}:8001`), `SOCKETCLUSTER_TOKEN_TTL` (default 900) and `SOCKETCLUSTER_ORIGIN` to `broadcasting.connections.socketcluster`. Set `SOCKETCLUSTER_ORIGIN` to the console origin when the socket server restricts origins. Nothing changes for socket clients until `SOCKETCLUSTER_AUTH_ENABLED=true`. Roll out in this order: - 1. Ship clients that fall back to connecting without a token when the token route answers 404. - 2. Set `SOCKETCLUSTER_AUTH_ENABLED=true` on the API and the socket server, with the socket server in `log` mode. - 3. Switch the socket server to `enforce`. -- Extensions: a relation that clients request through `with`, `expand` or counts must declare an Eloquent `Relation` return type or be listed in `$withable`, or it stops loading (#303). Every `dynamic:` notifiable an extension resolves must be passed to `registerNotifiable()` (#304). Models whose cached queries eager-load other tables should declare `$apiCacheDependencies`, and writes that skip Eloquent events need an explicit invalidation (#301). fleetbase/fleetops v0.6.72 and fleetbase/storefront v0.4.25 carry these and require this release (`fleetbase/core-api ^1.6.69`). -- Public API clients: `GET v1/comments` and `GET v1/comments/{id}` no longer return internal comments, `POST v1/comments` accepts `visibility`, and comment, chat participant and chat message responses gain fields (#306). -- To make the media bucket private, remove any public `s3:GetObject` statement from the bucket policy and turn on Block Public Access (#287). -- Database backups replace the old S3 backup settings; configure them with `DB_BACKUP_*` or in the admin settings (#288). +- None. The option is additive. +- fleetbase/storefront v0.4.25 requires this release (`fleetbase/core-api ^1.6.70`) for its phone change endpoint. diff --git a/composer.json b/composer.json index d1570eca..d3d21266 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.6.69", + "version": "1.6.70", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", diff --git a/src/Models/VerificationCode.php b/src/Models/VerificationCode.php index d31550c7..fed8e79d 100644 --- a/src/Models/VerificationCode.php +++ b/src/Models/VerificationCode.php @@ -381,9 +381,11 @@ public static function generateEmailVerificationFor($subject, $for = 'email_veri * @param mixed $subject the subject (typically a User model) to whom the SMS will be sent * @param string $for Context or purpose of the verification. Default is 'phone_verification'. * @param array $options Options to customize the verification process. Can include 'expireAfter' to set a custom expiration, - * 'meta' for additional metadata, and 'messageCallback' to customize the SMS message content. + * 'meta' for additional metadata, 'messageCallback' to customize the SMS message content, and 'phone' + * to text another number than the subject's own (a number the subject is changing to). * - * @return static returns the verification code instance after persisting it to the database and sending the SMS + * @return static returns the verification code instance after persisting it to the database and sending the SMS. + * Without a number to text (no 'phone' option and none on the subject) the code is saved but not sent. * * @throws \Fleetbase\Exceptions\SmsBlockedException when the SMS guard refuses the send; no code row is written * @throws \Exception throws an exception if the SMS cannot be sent @@ -391,7 +393,7 @@ public static function generateEmailVerificationFor($subject, $for = 'email_veri public static function generateSmsVerificationFor($subject, $for = 'phone_verification', array $options = []) { $expireAfter = data_get($options, 'expireAfter'); - $destination = data_get($subject, 'phone'); + $destination = data_get($options, 'phone') ?: data_get($subject, 'phone'); $companyUuid = data_get($options, 'company_uuid') ?? session('company') ?? data_get($subject, 'company_uuid'); // Ask the guard BEFORE the code row exists: a refused send must leave nothing @@ -474,14 +476,14 @@ public static function generateSmsVerificationFor($subject, $for = 'phone_verifi $provider = data_get($options, 'provider'); // Send SMS using SmsService with automatic provider routing - if ($subject->phone) { + if ($destination) { try { $smsService = new SmsService(); - $smsService->send($subject->phone, $message, $smsOptions, $provider); + $smsService->send($destination, $message, $smsOptions, $provider); } catch (\Throwable $e) { // Log error but don't fail the verification code generation \Illuminate\Support\Facades\Log::error('Failed to send SMS verification', [ - 'phone' => $subject->phone, + 'phone' => $destination, 'error' => $e->getMessage(), ]); diff --git a/tests/Unit/Models/VerificationCodeGuardTest.php b/tests/Unit/Models/VerificationCodeGuardTest.php index 28bac120..aa0dfe5d 100644 --- a/tests/Unit/Models/VerificationCodeGuardTest.php +++ b/tests/Unit/Models/VerificationCodeGuardTest.php @@ -173,6 +173,46 @@ function verification_code_guard_subject(string $phone): User ->and($entries->filter(fn ($name) => $name === 'sms.blocked')->count())->toBe(1); }); +it('texts the number given in the phone option instead of the subject\'s own, and guards that number', function () { + $twilio = verification_code_guard_database(); + + $code = VerificationCode::generateSmsVerificationFor(verification_code_guard_subject('+447400100123'), 'storefront_verify_phone', [ + 'phone' => '+447400100999', + 'meta' => ['phone' => '+447400100999'], + ]); + + // The new number has its own cooldown; the subject's own number was never texted. + $repeat = null; + try { + VerificationCode::generateSmsVerificationFor(verification_code_guard_subject('+447400100123'), 'storefront_verify_phone', ['phone' => '+447400100999']); + } catch (SmsBlockedException $e) { + $repeat = $e; + } + VerificationCode::generateSmsVerificationFor(verification_code_guard_subject('+447400100123'), 'storefront_verify_phone'); + + expect($code->meta['phone'])->toBe('+447400100999') + ->and($code->meta['trace']['destination_country'])->toBe('GB') + ->and($repeat)->toBeInstanceOf(SmsBlockedException::class) + ->and(collect($twilio->messages)->pluck('to')->all())->toBe(['+447400100999', '+447400100123']); +}); + +it('texts the phone option when the subject has no number of its own', function () { + $twilio = verification_code_guard_database(); + + VerificationCode::generateSmsVerificationFor(verification_code_guard_subject(''), 'storefront_verify_phone', ['phone' => '+447400100999']); + + expect(collect($twilio->messages)->pluck('to')->all())->toBe(['+447400100999']); +}); + +it('saves the code without texting anyone when there is no number at all', function () { + $twilio = verification_code_guard_database(); + + $code = VerificationCode::generateSmsVerificationFor(verification_code_guard_subject(''), 'phone_verification', ['phone' => null]); + + expect($code->exists)->toBeTrue() + ->and($twilio->messages)->toBe([]); +}); + it('leaves the code row untouched when the guard is disabled', function () { $twilio = verification_code_guard_database(['enabled' => false]);