Skip to content

release: v1.6.69 - #294

Merged
roncodes merged 74 commits into
mainfrom
release/v1.6.69
Oct 14, 2026
Merged

roncodes merged 74 commits into
mainfrom
release/v1.6.69

Conversation

@roncodes

@roncodes roncodes commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Release v1.6.69

Release branch for core-api v1.6.69. Merging into main triggers release.yml, which validates that composer.json and the first line of RELEASE.md name 1.6.69, then pushes the v1.6.69 tag.

This branch collects:

Before merging

- Drop the 'public' visibility from Utils::urlToStorefrontFile: a bucket with
  BucketOwnerEnforced rejects any PUT carrying an ACL, so put() returned false.
- Add File::signStoredUrl()/s3KeyFromUrl(): turn absolute URLs stored as strings
  (legacy unsigned bucket URLs, expired signed URLs) back into a key and re-sign.
- Re-sign template builder image src at render time.
- Cache signed URLs for 60 of their 120 minutes so every URL handed out has at
  least an hour left; cut Extension icon_url cache from 24h to 30m.
Replace the db:backup command, which piped mysqldump through gzip without
pipefail, swallowed upload errors and returned success on a failed dump.
That is how production uploaded 20-byte empty dumps on 2026-09-24/25 (no
mysqldump in the image) and then nothing at all while reporting DONE.

- DatabaseBackupService streams the dump client's stdout into gzip in PHP
  (no shell pipeline), passes the password via MYSQL_PWD, and fails a run
  on a non-zero exit, a missing '-- Dump completed' marker, a dump under
  min_size_bytes, or an uploaded object whose size differs from the file.
- Uploads go to any filesystem disk (bucket override for s3, key prefix);
  retention by age and/or count runs only after a fully successful run and
  always keeps each database's newest backup.
- Every attempt is recorded in database_backups (status, size, duration,
  error, trigger); failures can email configured addresses.
- Settings live in system.database-backups (env defaults in
  config/database-backups.php) and drive the schedule; disabled by default.
- Admin endpoints under int/v1/database-backups: settings get/save/reset,
  recent runs, and a queued 'run now'.
- db:backup exits non-zero on any failure; --force runs while disabled.
Add VerificationCode::issue(), check() and attemptsLeft() for flows where a
leaked table must not give away live codes:

- issue() stores an HMAC of the code, keyed by the app key, and hands the
  plain code back once on the instance (plainCode), defaulting to a 10-minute
  expiry and an 'active' status.
- check() compares with hash_equals, counts wrong attempts in meta and locks
  the code on the last allowed one. Expired and locked codes report as such.
- The creating hook keeps a code that was already set, so issue() is not
  overwritten; codes made the old way still get a random one and still check.

Existing generators and their callers are unchanged. First user: the FleetOps
public tracking page's one-time codes.
Adds the realtime channel authentication core, switched on by
SOCKETCLUSTER_AUTH_KEY (config auth_key, publish_url, token_ttl):

- SocketToken mints and verifies HS256 socket tokens (iss/aud/iat/nbf/exp/jti
  plus kind, sub, cid, cpid, env, ids, adm, scp, sid); anything not HS256 with
  the configured key, or with a wrong issuer/audience or out-of-range time
  claims, is rejected. Includes the scoped public tracking token.
- SocketSignature derives per-purpose HMAC keys and signs/verifies the
  timestamped requests exchanged with the socket server.
- SocketPrincipal, ChannelDecision, the SocketChannelResolver contract and the
  SocketChannelRegistry extensions register their channel prefixes with.
- ChannelAuthorizer applies install, expiry, scope, system and self rules,
  then the prefix resolver, caching decisions per token and channel.
- Core resolvers for company, api, user, test, install/uninstall, chat,
  chat_channel, chat_participant, chat_message and file channels.
- The registry and authorizer are container singletons.
- POST int/v1/socket/token (console session): a user token for the current
  company, in the sandbox environment when the console is in sandbox mode.
- POST v1/socket/token (public API): an api token for an API credential; for a
  Sanctum user token, the principal a registered resolver claims, else a user
  token.
- POST int/v1/socket/authorize: called by the socket server only, admitted by
  its signature (VerifySocketSignature) rather than a session; re-verifies the
  token and returns {allow, ttl, reason}. Exempt from the configured-instance
  check so an install page can follow the install channel before setup ends.
- Every mint route answers 404 while SOCKETCLUSTER_AUTH_KEY is unset.
When SOCKETCLUSTER_AUTH_KEY is set, the broadcaster and
SocketClusterService::publish()/send() post every channel of a broadcast in a
single request to {SOCKETCLUSTER_PUBLISH_URL}/publish, signed with the derived
publish key and short timeouts. Without the key the websocket publisher is
used as before.

Channels ending in "." (an empty suffix, e.g. a session read in a queue
worker) and names the socket server would reject are dropped before
publishing.
… channel

The SocketCluster settings test ignored nothing: any admin could publish an
arbitrary payload to any channel. It now always publishes to
test.{current user uuid} and returns that channel so the console can
subscribe to it.
POST v1/socket/system-token in the fleetbase.platform-api group mints a
system token. A separate path because the platform and public API groups
share the v1 prefix, where v1/socket/token is the public API mint route.
…annel decision

The install-channel test skipped the users table but still seeded it. The
cross-company and driver tests now compare every channel's decision reason
(and any resolver error logged) in one assertion.
… model

ChatMessage always eager loads its attachments, so authorizing a chat_message
channel queried chat_attachments for nothing (and failed where that table is
absent). Channel lookups now load only the model's own row.
getCountryCodeByCurrency() and getCountryCodeByName() rebuilt the full
countries dataset (a 4.7 MB JSON file plus flag hydration) on every call.
Storefront serializes a country per store, so listing stores paid that cost
once per record and network store lists could take minutes.

The name/ISO2/currency rows are now built once, kept in the application
cache and memoized per process. Cache failures fall back to building the
lookup, and flushCountryLookup() resets it.
Store media, product images and proofs of delivery are looked up by
subject_uuid, which had no index, so each lookup scanned the whole files
table.
fix(files): support a fully private S3 media bucket
feat(backups): settings-driven database backups that fail loudly
feat(verification): hashed one-time codes with attempt counting
perf: cache the country lookup and index files.subject_uuid
Socket authentication was on as soon as SOCKETCLUSTER_AUTH_KEY was set. That
also moved every broadcast to the socket server's HTTP publish endpoint, so
provisioning the key before every client fetched tokens (or before the new
socket server was deployed) would break existing socket clients.

- New `broadcasting.connections.socketcluster.auth_enabled`
  (SOCKETCLUSTER_AUTH_ENABLED, default false). SocketToken::enabled() now
  needs the switch and a valid key. Every gated path follows: token routes,
  the authorize endpoint and its signature check, and HTTP publishing.
- With the switch off, the console's socket test publishes to the requested
  channel (default `test`) as before. With it on, only to `test.{user}`.
- README: the switch and the rollout order (ship clients that fall back on
  404, switch on with the socket server in log mode, then enforce).
RunDatabaseBackup::dispatch() comes from the Dispatchable trait. Several test
files define an empty Illuminate\Foundation\Bus\Dispatchable shim, so when one
of them loads first the static dispatch() is missing and DatabaseBackupsTest
fails depending on test order (PHP CI on release/v1.6.69 fails this way).
Dispatching through the bus contract behaves the same at runtime and does not
depend on the trait.
fix(backups): queue the manual backup through the bus dispatcher
…er handshake

The PHP publisher connected without an Origin header, which socketcluster-server
treats as '*'. With origins restricted (scripts/docker-install.sh restricts them
to the console host), every server broadcast was refused with 'Invalid origin: *'.
SOCKETCLUSTER_ORIGIN now sets the header through phrity/websocket's headers
option. Reported in #290.
@codecov

codecov Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (ab33100) to head (ca2da3e).
⚠️ Report is 76 commits behind head on main.

Additional details and impacted files
@@              Coverage Diff              @@
##                main      #294     +/-   ##
=============================================
  Coverage     100.00%   100.00%             
- Complexity      7931      8880    +949     
=============================================
  Files            438       472     +34     
  Lines          25665     27918   +2253     
=============================================
+ Hits           25665     27918   +2253     
Flag Coverage Δ
backend 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

feat(socket-auth): authenticated realtime channels (tokens, authorizer, signed publish)
DB_BACKUP_BUCKET now has no default, so an install writes to the bucket it
configures rather than a hard-coded name.
- deleteRecord passes the bare column name to isColumn(), matching the
  other record lookups in HasApiModelBehavior
- add ReportFilter so report listings follow the same company constraint
  as other company-owned resources
- tests for both
…ttings

resolveNotifiable() read the definition from the company's notification_settings,
which saveSettings stores as given. dynamic:<property> reached any public
no-argument method on the subject, through resolveDynamicNotifiable() or the
Eloquent relation getter, so dynamic:dispatch ran Order::dispatch() whenever a
notification fired; a class definition was built with app(). Definitions not
registered with registerNotifiable() are now skipped.
The coverage gate needs 100% per file. A typed relation that throws on a
blank model is now tested (it is dropped), as is mutateModelWithRequest()
on a non-model. The Expandable branch loses a Closure check that could
never fail: isExpansion() only holds for a registered Closure.
getSessionScope returns no scope when the request has no session. Every test
request had one, so the release branch fell one statement short of the coverage
floor after #300.
getCompanyUuid called $request->session() unguarded, so generateQueryCacheKey
threw "Session store not set on request." before getSessionScope's hasSession
check could apply. Check hasSession first and fall back to the user and request
input as before. The test now covers the sessionless path through
generateQueryCacheKey instead of calling getSessionScope by reflection.
feat(api-cache): writes invalidate every cache that embeds the written table
…let extensions relate ids

Core no longer names storefront. Every route that runs LogApiRequests opted in
through its middleware group (core's public API or an extension's), so
shouldLog only skips internal routes and requests without a route, instead of
requiring the core v1/ prefix. isPublicRequest is back to v1/ only, so
storefront filters and resources are unchanged.

LogApiRequest::RELATED_ATTRIBUTE ("api_request_log_related") is a request
attribute whose ids are added to the log's related column. Extensions set it
from their middleware, for example storefront with the public id of the store
or network whose key made the request.
… methods and an unresolvable transactions manager

#301 left three statements in ApiModelCache unrun, so the release branch is
under the 100% coverage floor.
fix(api-logs): log every route that opts in to LogApiRequests and let extensions relate ids
fix(api): only real relations can be requested through with, expand and counts
…lowlist

fix(notifications): resolve only registered notifiables from saved notification settings
…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.
…annels

addRelationshipSpecificChannels checked isset($model->driver_assigned_uuid) but then built the
channel from $model->driverAssigned_uuid, an attribute that never exists, so every order lifecycle
event broadcast on a dead 'driverAssigned.' channel instead of 'driverAssigned.{driver uuid}'.
Read the same snake case key it checks.
…st comment.created

Comments carry meta.visibility = internal | customer (a 'visibility' shorthand is accepted on
create). When none is given: replies follow their parent, comments on orders are internal when
written through an internal (console) route and customer from anywhere else, and comments on other
subjects keep no visibility, which stays visible, so existing data and callers are unaffected.

- Comment::scopeVisibleToCustomers() (everything but internal) and scopeInternal(),
  getVisibility(), isInternal(), isVisibleToCustomers()
- the public v1 comments API excludes internal comments from the listing (CommentFilter
  queryForPublic) and find, accepts an optional visibility on create, and the Comment resource
  hides internal replies outside internal requests and exposes a top level 'visibility'
- new CommentCreated event broadcasts 'comment.created' with the comment resource on
  company.{company_uuid} for every comment and, for customer visible comments only, on the
  subject's channels ({subject type}.{public_id} and {subject type}.{uuid}, e.g. order.order_x);
  a broadcast failure is logged and never fails the comment
Adds a nullable json meta column to chat_participants and chat_messages (guarded migration).

- ChatParticipant: meta (fillable, json cast), ROLE_* constants (customer, driver, store, system)
  and getRole() reading meta.role; the resource adds 'role' and 'meta', so a message's sender.role
  reaches the apps over HTTP and the chat_message broadcasts
- ChatMessage: meta (fillable, json cast), isSystem() (meta.system) and getSystemKind()
  (meta.kind); the resource adds 'system' and 'meta' and renders a missing sender as null instead
  of failing (the duplicate public 'sender' key it dropped was always overwritten by the
  participant resource)

The public send-message endpoint still builds its own attributes, so customers cannot post system
messages or set roles; existing chat behaviour is unchanged.
…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).
…nd the admin API

Phase 1 of the SMS guard (phase 0 is the guard itself):

- sms_sends ledger: every allowed send is reserved and settled with the provider
  outcome, every refusal is written with its reason, with tenant, purpose, source,
  address, destination country and the code row it carried. sms:ledger:prune masks
  numbers after 30 days and deletes rows after 90 (both configurable).
- verification_codes gain company_uuid, identity, channel, ip_address, user_agent,
  sms_send_uuid and consumed_at (identity backfilled from meta for recent rows).
  generateSmsVerificationFor(['hashed' => true]) stores an HMAC and hands the
  message callback the plain code; findActiveFor()/findActiveForSubject() find the
  newest live code (new column or legacy meta), check() verifies, consume() uses
  it up. Everything degrades to meta-only on a table without the columns.
- Two breaker signals read from the data: a burst to a country the company has
  never texted (armed after 50 ledgered sends), and SMS codes issued with almost
  none verified in the last hour.
- SmsServiceChannel: a notification channel that sends through SmsService, so
  notification texts are guarded and ledgered too.
- Admin API under int/v1/sms-guard: settings (mode, budgets, blocklists, alert
  recipients, per-company exceptions), reset, pauses, usage per organization and
  the ledger with filters. The console page follows separately.
- The unrouted legacy Twilio-direct SMS login methods are removed.
feat(sms): guard every SMS send with destination validation, budgets and a circuit breaker
…-visibility

Fix driver uuid broadcast channel; comment visibility and comment.created; chat participant roles and system message meta
feat(sms): ledger, hashed single-use codes, learned breaker signals and the admin API
…eft behind

#307 removed the unrouted legacy SMS login methods from AuthController along
with their tests in AuthControllerVerificationTest, but the same cases also
lived in AuthControllerLoginBootstrapTest. Those five now fail PHP CI on
release/v1.6.69 with BadMethodCallException.
The clover artifact is what shows which lines are short, and it was only
uploaded after a passing gate.
…gate flagged

The release branch's coverage gate was never evaluated while the orphaned
authenticateSmsCode tests failed first. With those gone it reports 73 statements
short across the SMS guard, the comment broadcast, the exception handler and the
notification registry. This covers them:

- breaker: expired pauses, a second hour at budget, the platform threshold, two
  signals tripping on one send, sends with no company, a missing platform
  counter, scope lookups and conversion reads when a table is gone, and alert
  delivery to administrators and plain addresses, including a failed delivery
- guard: a pause recorded in monitor mode, the configured cache store and its
  fallback; alert: every signal's wording, via() and toMail()
- context: a network tenant, an unresolvable request, an unknown path
- ledger and policy: a cache that is itself unreachable, tables that cannot be
  queried, purposes without a name or rules; validator: a number the parser
  rejects outright
- comment broadcasts: a bare, preloaded, unknown or broken subject
- handler: a refused SMS keeps its status and Retry-After (the test response
  factory now passes headers through, as the real one does)
- verification codes: legacy rows on a table without the tracing columns and a
  code issued with no request to read the client address from
- notification registry: an unknown definition and a registered notifiable that
  is not a model

Two catch blocks the harness cannot reach (app() and session() never fail in it)
are marked for coverage with the reason.
docs(release): v1.6.69 notes cover everything merged since #291
@roncodes
roncodes merged commit f7bb580 into main Oct 14, 2026
4 checks passed
@roncodes
roncodes deleted the release/v1.6.69 branch October 14, 2026 04:50
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