Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
135df76
ci: point the Postman contract at the v0.7.53 release
roncodes Aug 7, 2026
db2d05c
ci: unpin the contract workflow now that it tracks latest
roncodes Aug 8, 2026
2c1605a
Fix Sentry config probe validation
roncodes Aug 8, 2026
c0f4fb5
Restore Sentry probe coverage gate
roncodes Aug 8, 2026
1f7fb62
v1.6.56
roncodes Aug 10, 2026
c5cabea
Merge pull request #235 from fleetbase/ci/postman-contract-v0.7.53
roncodes Aug 10, 2026
cc4a446
ci: run PHP CI and the Postman contract on dev-v* release branches
roncodes Aug 10, 2026
541842d
fix(exceptions): stop rendering HTML stack traces to API clients
roncodes Aug 8, 2026
07aec59
Merge pull request #236 from fleetbase/feature/api-json-error-responses
roncodes Aug 10, 2026
05d63aa
Add safe user deletion command
roncodes Aug 10, 2026
5087bb1
ci(postman): test this branch's API code, not the published package
roncodes Aug 10, 2026
c32defb
Merge pull request #238 from fleetbase/ci/contract-overlay-branch-source
roncodes Aug 10, 2026
0cd10aa
fix(api): let the public download endpoint take a public_id
roncodes Aug 10, 2026
fb69722
fix(api): $request->user() was null on every public API request
roncodes Aug 11, 2026
557b937
fix(support): a multi-table findModel miss queried a table named "Array"
roncodes Aug 11, 2026
502a70f
style: shorten the now-redundant User FQCN in the auth middleware
roncodes Aug 11, 2026
c339c82
fix(mail): a user without a name made verification email throw
roncodes Aug 11, 2026
0fb6464
fix(tests): remove a second-boundary race in the api credential expir…
roncodes Aug 12, 2026
64dec44
ci(contract): run the API contract once per commit
roncodes Aug 12, 2026
e76a474
Revert "ci(contract): run the API contract once per commit"
roncodes Aug 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
name: PHP CI

# Release work lands on a dev-v* branch first and only reaches main via the release
# PR, so a main-only filter leaves every PR targeting a release branch with no CI at
# all — the release is then assembled from unverified commits.
on:
push:
branches: [ main ]
branches: [ main, 'dev-v*' ]
tags:
- 'v*'
pull_request:
branches: [ main ]
branches: [ main, 'dev-v*' ]

jobs:
build:
Expand Down
21 changes: 16 additions & 5 deletions .github/workflows/postman.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,33 @@ name: API Contract (Postman)
# collection against the live API. Delegates to the reusable workflow in
# fleetbase/fleetbase. Requires org secrets POSTMAN_API_KEY + _GITHUB_AUTH_TOKEN
# (inherited); no-ops until POSTMAN_API_KEY is set.
# TODO: change @dev-v0.7.53 to @main once that branch is merged.
#
# Deliberately unpinned. The reusable workflow defaults to booting fleetbase/fleetbase@main
# against fleetbase/fleetbase-api:latest, so every release is picked up automatically and
# there is no ref here to remember to bump. Each run records the image digest it actually
# resolved in its job summary, so a result stays traceable. To reproduce an older run:
#
# with:
# fleetbase-ref: v0.7.53
# api-image: fleetbase/fleetbase-api:v0.7.53

on:
push:
branches: [main]
branches: [main, 'dev-v*']
pull_request:
branches: [main]
branches: [main, 'dev-v*']
workflow_dispatch:

permissions:
contents: read

jobs:
contract:
uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@dev-v0.7.53
uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@main
with:
collections: "Fleetbase Core API"
build-from-source: false
# Without this the run tests the version of fleetbase/core-api baked into the
# published image, not the branch under review. The workflow checks this
# repository out at the commit under test and swaps it into the container.
overlay-package: fleetbase/core-api
secrets: inherit
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fleetbase/core-api",
"version": "1.6.55",
"version": "1.6.56",
"description": "Core Framework and Resources for Fleetbase API",
"keywords": [
"fleetbase",
Expand Down
119 changes: 119 additions & 0 deletions src/Console/Commands/DeleteUser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

namespace Fleetbase\Console\Commands;

use Fleetbase\Services\UserDeletionService;
use Illuminate\Console\Command;
use Illuminate\Support\Str;

/**
* @phpstan-import-type UserDeletionPlan from UserDeletionService
*/
class DeleteUser extends Command
{
protected $signature = 'fleetbase:user-delete
{--email= : Delete every user matching this email}
{--uuid=* : Delete one or more users by UUID}
{--execute : Execute the displayed deletion plan}
{--yes : Skip the interactive confirmation}';

protected $description = 'Safely preview and delete users and their identity-bound resources across Fleetbase schemas';

public function __construct(protected UserDeletionService $deletionService)
{
parent::__construct();
}

public function handle(): int
{
$emailOption = $this->option('email');
$email = is_string($emailOption) && $emailOption !== '' ? $emailOption : null;
$uuids = array_values(array_unique(array_filter((array) $this->option('uuid'), 'is_string')));

if (($email && $uuids !== []) || (!$email && $uuids === [])) {
$this->error('Provide exactly one selector: --email or --uuid.');

return self::FAILURE;
}

$invalidUuids = array_values(array_filter($uuids, fn ($uuid) => !Str::isUuid($uuid)));
if ($invalidUuids !== []) {
$this->error('Invalid UUIDs: ' . implode(', ', $invalidUuids));

return self::FAILURE;
}

$users = $this->deletionService->findUsers($email, $uuids);
if ($users->isEmpty()) {
$this->warn('No matching users were found.');

return self::SUCCESS;
}

$selectedUuids = [];
$userRows = [];
foreach ($users as $user) {
$selectedUuids[] = $user->uuid;
$userRows[] = [$user->uuid, $user->email, $user->name];
}
$this->table(['UUID', 'Email', 'Name'], $userRows);

$plan = $this->deletionService->plan($selectedUuids);
$this->displayPlan($plan);

if ($plan['blockers'] !== []) {
$this->error('Deletion is blocked by unresolved references: ' . implode(', ', $plan['blockers']));

return self::FAILURE;
}

if (!$this->option('execute')) {
$this->info('Dry run only. Re-run with --execute to apply this plan.');

return self::SUCCESS;
}

if (!$this->option('yes') && !$this->confirm('Permanently delete the displayed users and apply this cleanup plan?')) {
$this->warn('Deletion cancelled.');

return self::SUCCESS;
}

try {
$result = $this->deletionService->execute($selectedUuids);
} catch (\Throwable $error) {
$this->error('Deletion failed and was rolled back: ' . $error->getMessage());

return self::FAILURE;
}

$deleted = (int) ($result['users_deleted'] ?? 0);
$this->info("Deleted {$deleted} users successfully.");

return self::SUCCESS;
}

/**
* @param UserDeletionPlan $plan
*/
protected function displayPlan(array $plan): void
{
$rows = [];
foreach ($plan['actions'] as $action) {
if ($action['count'] === 0) {
continue;
}

$rows[] = [
$action['schema'],
$action['table'],
$action['column'],
strtoupper($action['action']),
$action['count'],
$action['reason'],
];
}

$this->table(['Schema', 'Table', 'Column', 'Action', 'Rows', 'Reason'], $rows);
}
}
48 changes: 44 additions & 4 deletions src/Exceptions/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,46 @@ public function render($request, \Throwable $exception)
return parent::render($request, $exception);
}

/**
* Determine if the exception should be rendered as JSON.
*
* Fleetbase is a JSON-only HTTP surface, but clients do not reliably send an
* `Accept: application/json` header. Laravel's default would then render an HTML
* error page for any exception not covered by shouldManuallyHandleException(),
* leaking file paths and stack frames to API consumers. Outside of local debugging
* always answer with JSON; when debugging is on the HTML page is kept, since it is
* the more useful development affordance.
*
* @param \Illuminate\Http\Request $request
*/
protected function shouldReturnJson($request, \Throwable $e): bool
{
if (!config('app.debug')) {
return true;
}

return parent::shouldReturnJson($request, $e);
}

/**
* Convert the given exception to an array.
*
* Matches the `{"errors": [...]}` envelope used by response()->error() so error
* payloads are consistent across the API, and withholds internals when debugging
* is off. HTTP exception messages are preserved because they describe the request
* the caller already made; anything else collapses to a generic message.
*/
protected function convertExceptionToArray(\Throwable $e): array
{
if (config('app.debug')) {
return parent::convertExceptionToArray($e);
}

$message = $this->isHttpException($e) && $e->getMessage() !== '' ? $e->getMessage() : 'Server Error';

return ['errors' => [$message]];
}

/**
* Retrieves a loggable message from an exception for CloudWatch.
*
Expand Down Expand Up @@ -162,16 +202,16 @@ private function manuallyHandleException(\Throwable $exception): ?\Illuminate\Ht

switch ($type) {
case 'TokenMismatchException':
return response()->error('Invalid XSRF token sent with request.');
return response()->error('Invalid XSRF token sent with request.', 419);

case 'ThrottleRequestsException':
return response()->error('Too many requests.');
return response()->error('Too many requests.', 429);

case 'AuthenticationException':
return response()->error('Unauthenticated.');
return response()->error('Unauthenticated.', 401);

case 'NotFoundHttpException':
return response()->error('There is nothing to see here.');
return response()->error('There is nothing to see here.', 404);

case 'ModelNotFoundException':
return response()->error($this->modelNotFoundMessage($exception), 404);
Expand Down
2 changes: 1 addition & 1 deletion src/Http/Controllers/Api/v1/FileController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
namespace Fleetbase\Http\Controllers\Api\v1;

use Fleetbase\Http\Controllers\Controller;
use Fleetbase\Http\Requests\Internal\DownloadFileRequest;
use Fleetbase\Http\Requests\DownloadFileRequest;
use Fleetbase\Http\Requests\Internal\UploadBase64FileRequest;
use Fleetbase\Http\Requests\Internal\UploadFileRequest;
use Fleetbase\Http\Resources\DeletedResource;
Expand Down
19 changes: 17 additions & 2 deletions src/Http/Controllers/Internal/v1/SettingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -906,26 +906,41 @@ protected function setTemporarySmsProviderConfig(string $provider, array $provid
*/
public function testSentryConfig(AdminRequest $request)
{
$dsn = $request->input('dsn');
$dsn = $request->input('dsn');
$clientDsn = $dsn;

// Set config from request
config(['sentry.dsn' => $dsn]);

if (is_string($dsn) && $dsn !== '') {
try {
$clientDsn = \Sentry\Dsn::createFromString($dsn);
} catch (\InvalidArgumentException) {
return response()->json([
'status' => 'error',
'message' => 'The provided Sentry DSN is invalid.',
]);
}
}

$message = 'Sentry configuration is successful, test Exception sent.';
$status = 'success';
$clientBuilder = null;

try {
$clientBuilder = \Sentry\ClientBuilder::create([
'dsn' => $dsn,
'dsn' => $clientDsn,
'release' => env('SENTRY_RELEASE'),
'environment' => app()->environment(),
'traces_sample_rate' => 1.0,
]);
// @codeCoverageIgnoreStart
// Sentry client construction errors depend on SDK versions that throw instead of normalizing invalid options.
} catch (\Exception $e) {
$message = $e->getMessage();
$status = 'error';
}
// @codeCoverageIgnoreEnd

if ($clientBuilder) {
// Set the Laravel SDK identifier and version
Expand Down
42 changes: 39 additions & 3 deletions src/Http/Middleware/AuthenticateOnceWithBasicAuth.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Fleetbase\Http\Middleware;

use Fleetbase\Models\ApiCredential;
use Fleetbase\Models\User;
use Fleetbase\Support\Auth;
use Fleetbase\Support\Utils;
use Illuminate\Http\Request;
Expand Down Expand Up @@ -47,7 +48,7 @@ public function authenticatedWithBasic(Request $request, $connection = null)

// Check if sanctum token
if ($sanctumToken = $this->getSanctumToken($token)) {
return $this->authenticateSanctumToken($sanctumToken);
return $this->authenticateSanctumToken($sanctumToken, $request);
}

// Check if secret key
Expand Down Expand Up @@ -97,6 +98,16 @@ public function authenticatedWithBasic(Request $request, $connection = null)
// Login user
Auth::setSession($apiCredential);

// Bind the user resolver so $request->user() answers on the public API.
//
// Auth::setSession() writes session('user') but takes $login = false, so nothing
// ever binds a resolver and the default guard is session-based with no login —
// meaning $request->user() was null on EVERY public API request. Extensions that
// reasonably read it got nothing: the ledger wallet routes answered 401 to every
// credential, and fleetops' tokenless register-device answered 404, both because
// the authenticated identity was invisible through the standard accessor.
static::bindUserResolver($request, User::find($apiCredential->user_uuid));

// Set sandbox session if applicable
Auth::setSandboxSession($request, $apiCredential);

Expand All @@ -109,9 +120,9 @@ public function authenticatedWithBasic(Request $request, $connection = null)
/**
* Authenticate the request using Sanctum token.
*/
private function authenticateSanctumToken(PersonalAccessToken $sanctumToken)
private function authenticateSanctumToken(PersonalAccessToken $sanctumToken, ?Request $request = null)
{
if ($sanctumToken && $sanctumToken->tokenable instanceof \Fleetbase\Models\User) {
if ($sanctumToken && $sanctumToken->tokenable instanceof User) {
// Make sure company is set
if (!Str::isUuid($sanctumToken->tokenable->company_uuid)) {
return response()->error('Oops! The api credentials provided were not valid', 401);
Expand All @@ -120,6 +131,10 @@ private function authenticateSanctumToken(PersonalAccessToken $sanctumToken)
// Set user to session
Auth::setSession($sanctumToken->tokenable);

// Same reasoning as above: a driver or customer authenticating with their own
// token is exactly the case where $request->user() ought to answer.
static::bindUserResolver($request, $sanctumToken->tokenable);

// Get API Credential for User
$apiCredential = ApiCredential::where('company_uuid', $sanctumToken->tokenable->company_uuid)->first();
if ($apiCredential) {
Expand All @@ -135,6 +150,27 @@ private function authenticateSanctumToken(PersonalAccessToken $sanctumToken)
return response()->error('Oops! The api credentials provided were not valid', 401);
}

/**
* Make the authenticated user visible through $request->user().
*
* Only sets a resolver when one is not already bound, so a guard that genuinely
* authenticated the request (the internal session routes) always wins.
*/
protected static function bindUserResolver(?Request $request, $user): void
{
if (!$request instanceof Request || !$user instanceof User) {
return;
}

// getUserResolver() never returns null — Request falls back to a closure that
// yields null — so the presence of a resolved user is the only usable signal.
if ($request->user() instanceof User) {
return;
}

$request->setUserResolver(static fn () => $user);
}

/**
* Get an instance of the PersonalAccessToken if valid.
*/
Expand Down
Loading
Loading