Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
383c8f4
fix(files): make uploads work with a private S3 media bucket
roncodes Oct 6, 2026
e6ef26d
feat(backups): settings-driven database backups that fail loudly
roncodes Oct 6, 2026
7108392
test(backups): align operators for php-cs-fixer
roncodes Oct 6, 2026
81b0245
feat(verification): hashed one-time codes with attempt counting
roncodes Oct 6, 2026
c8eae51
feat(socket-auth): socket tokens, principals and channel authorization
roncodes Oct 6, 2026
7e6f3c6
feat(socket-auth): socket token and channel authorize endpoints
roncodes Oct 6, 2026
5b13f21
feat(socket-auth): publish broadcasts with one signed HTTP request
roncodes Oct 6, 2026
819aa38
fix(socket-auth): admin socket test publishes only to the admin's own…
roncodes Oct 6, 2026
3222eb4
docs(socket-auth): document realtime channel authentication
roncodes Oct 6, 2026
f2e142f
feat(socket-auth): system socket token route for platform API callers
roncodes Oct 6, 2026
4ff2c6f
test(socket-auth): fix the missing-schema fixture and report every ch…
roncodes Oct 6, 2026
2327045
fix(socket-auth): skip default eager loads when resolving a channel's…
roncodes Oct 6, 2026
1e9e0c7
perf: cache the country lookup used by currency and name helpers
roncodes Oct 6, 2026
d569a22
perf: index files.subject_uuid
roncodes Oct 6, 2026
a580cde
chore(release): v1.6.69
roncodes Oct 7, 2026
5e902f2
Merge pull request #287 from fleetbase/fix/private-media-bucket
roncodes Oct 7, 2026
dcaeaed
Merge pull request #288 from fleetbase/feature/database-backups
roncodes Oct 7, 2026
b8e6c2f
Merge pull request #289 from fleetbase/feature/hashed-verification-codes
roncodes Oct 7, 2026
8072783
Merge pull request #291 from fleetbase/fix/country-lookup-cache
roncodes Oct 7, 2026
1111451
feat(socket-auth): SOCKETCLUSTER_AUTH_ENABLED switch, off by default
roncodes Oct 7, 2026
b02d948
fix(backups): queue the manual backup through the bus dispatcher
roncodes Oct 7, 2026
0093a1c
docs(release): socket auth stays off until SOCKETCLUSTER_AUTH_ENABLED…
roncodes Oct 7, 2026
3028118
Merge pull request #295 from fleetbase/fix/database-backup-dispatch
roncodes Oct 8, 2026
f961a5d
fix(socket-auth): send a configurable Origin on the websocket publish…
roncodes Oct 8, 2026
0a08e12
Merge pull request #290 from fleetbase/feature/socket-auth
roncodes Oct 8, 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
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,42 @@ Notes:
- Transformers may return `MissingValue` / `MergeValue` objects; they are filtered like `when()` / `merge()` output. Keys excluded with `without()` stay excluded.
- Re-registering a class replaces its options; `ResourceTransformerRegistry::forget()` and `reset()` remove registrations.
- The registry is a container singleton (`app(ResourceTransformerRegistry::class)`); registrations happen at boot and are shared by every request in an Octane worker.

## Realtime channel authentication

Authenticated realtime channels are on only when `SOCKETCLUSTER_AUTH_ENABLED=true` **and** `SOCKETCLUSTER_AUTH_KEY` is set (a shared secret of at least 32 characters, also given to the socket server). Until then nothing changes: no socket tokens are minted, the token routes answer 404, broadcasts use the websocket publisher as before, and the console's socket test publishes to the channel it asks for.

The switch is separate from the key so a deployment can provision the key ahead of time and keep every existing socket client working (mobile apps, the console, integrations) until they all fetch socket tokens. Roll out in this order:
1. Ship clients that request a socket token and fall back to connecting without one when the token route answers 404.
2. Set `SOCKETCLUSTER_AUTH_ENABLED=true` on the API, queue and scheduler, and run the socket server with `SOCKETCLUSTER_AUTH_MODE=log`.
3. Check the socket server's deny log, then switch it to `enforce`.

| Variable | Default | Meaning |
|---|---|---|
| `SOCKETCLUSTER_AUTH_ENABLED` | `false` | Turns authenticated realtime channels on. Has no effect without `SOCKETCLUSTER_AUTH_KEY`. |
| `SOCKETCLUSTER_AUTH_KEY` | unset | Signs socket tokens (HS256) and, through derived keys, the API to socket server requests. |
| `SOCKETCLUSTER_PUBLISH_URL` | `http://{SOCKETCLUSTER_HOST}:8001` | The socket server's internal listener; broadcasts are sent as one signed `POST {url}/publish`. |
| `SOCKETCLUSTER_TOKEN_TTL` | `900` | Lifetime in seconds of user, API, driver, customer and checkout tokens. |
| `SOCKETCLUSTER_ORIGIN` | unset | `Origin` header the websocket publisher sends on its handshake. Set it to an origin the socket server allows (e.g. the console URL) when `SOCKETCLUSTER_OPTIONS` restricts `origins`; without it the handshake is refused as `Invalid origin: *`. Not used by the signed HTTP publish. |

Clients fetch a token before connecting: `POST int/v1/socket/token` (console session), `POST v1/socket/token` (API credential or Sanctum user token). The socket server asks `POST int/v1/socket/authorize`, signed with its own derived key, whether a token may subscribe to a channel.

A channel is authorized by the resolver registered for its prefix (the part before the first `.`); unknown prefixes are denied. Extensions register theirs from their service provider:

```php
use Fleetbase\Support\SocketCluster\SocketChannelRegistry;
use Fleetbase\Support\SocketCluster\SocketPrincipal;

$registry = app(SocketChannelRegistry::class);

// `order.{uuid|public_id}`: users and API credentials of the order's company; drivers only when $narrow agrees.
$registry->registerModel('order', Order::class, fn (SocketPrincipal $p, Order $order) => $p->kind === 'driver' && $p->owns((string) $order->driver_assigned_uuid));

// Anything else: fn (SocketPrincipal $p, string $id, string $channel): bool
$registry->register('fleet', fn (SocketPrincipal $p, string $id, string $channel) => /* ... */ false);

// Claim a Sanctum-authenticated user as a more specific principal on `POST v1/socket/token`.
$registry->registerPrincipalResolver(fn (Request $request, $user) => /* ?SocketPrincipal */ null);
```

`app(ChannelAuthorizer::class)->authorize($principal, $channel)` gives the same decision anywhere in PHP.
30 changes: 16 additions & 14 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,26 +1,28 @@
# v1.6.68 — Resource transformers apply to every resource
# v1.6.69 — Authenticated realtime channels, private media, database backups and hashed codes

## Added

- **Agnostic resource transformers.** Any extension can decorate the serialized output of any API resource without modifying the resource or its model. Register a transformer against an HTTP resource class, an Eloquent model class, an interface, or `'*'` (subclasses match), and `FleetbaseResource::resolve()` applies it to JSON responses, nested resources, collection items, webhook payloads and broadcast payloads. Transformers chain in ascending `priority` and can be scoped by `contexts` (`http`, `webhook`, `broadcast`) and `only` (`internal`, `public`). (#285)
- `Fleetbase\Contracts\ResourceTransformer`, `Fleetbase\Contracts\PreparesResourceTransformation` (a once-per-collection `prepare()` hook for batch loading, so transformers never add N+1 queries), `Fleetbase\Support\ResourceTransformerContext`, and the `Fleetbase\Http\Transformers\Transformer` base class.
- Closure transformers via `ResourceTransformerRegistry::register(fn (...) => ..., ['target' => ...])`.
- `CoreServiceProvider::$transformers`, `registerTransformers()` and `registerTransformersFrom(__DIR__ . '/../Http/Transformers')` for declarative and directory-based registration from extensions, mirroring expansions.
- **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, 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. (#290)
- **Database backups.** Settings-driven backups (`db:backup`) on a configurable schedule, with environment defaults in `config/database-backups.php` (`DB_BACKUP_*`) and an admin override. Failures email the configured addresses and are logged. (#288)
- **Hashed one-time codes.** `VerificationCode::issue()` stores an HMAC of the code and returns the plain code once. `check()` counts attempts and reports `valid`, `invalid`, `expired` or `locked`. The existing generators are unchanged. (#289)

## Changed

- `FleetbaseResourceCollection` resolves items (instead of calling `toArray()`), sharing one `prepare()` pass per collection. A hand-built collection with a manually set `preserveKeys` now filters item arrays with the item's flag.
- `ResourceLifecycleEvent` payloads, chat participant broadcasts, `Utils::serializeJsonResource()` and the cached internal user payload serialize through `resolve()`, so transformers reach them and conditional `MissingValue`s are no longer emitted as `{}`.
- `Find::httpResourceForModel()` caches internal and public resolutions separately, consulting the request only when a model has a dedicated `Internal` resource.
- **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)

## Removed

- Legacy duck-typed transformers (`$target` property + static `output($model, $data)`), `ResourceTransformerRegistry::transform(Model, array)`, `resolveByTarget()`, `fixClassName()` and the static `$transformers` array. The `User` resource no longer calls the registry directly.

## Dependencies

- `fleetbase/laravel-mysql-spatial` `^1.0.3`. The spatial `MysqlConnection` no longer connects to MySQL when the connection object is built, so resolving `DB::connection()` during boot (for example `artisan package:discover` during `composer install`) no longer requires a reachable database.
- `MysqlS3Backup`, `S3BackupTrimmer` and `config/laravel-mysql-s3-backup.php`, replaced by the new database backups. (#288)

## Upgrade Steps

- Extensions that registered a legacy transformer must implement `Fleetbase\Contracts\ResourceTransformer` (or extend `Fleetbase\Http\Transformers\Transformer`) and register it through `$transformers` or `registerTransformersFrom()`. See the README section "Resource transformers". The only known legacy consumer, aws-marketplace, is deprecated and is not updated.
- Run migrations: `database_backups` table (#288) and the `files.subject_uuid` index (#291).
- Socket auth (#290) adds `SOCKETCLUSTER_AUTH_ENABLED` (default `false`), `SOCKETCLUSTER_AUTH_KEY`, `SOCKETCLUSTER_PUBLISH_URL` (default `http://{SOCKETCLUSTER_HOST}:8001`) and `SOCKETCLUSTER_TOKEN_TTL` (default 900) to `broadcasting.connections.socketcluster`. 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`.
- 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).
- fleetbase/storefront v0.4.25 and fleetbase/fleetops#358 require this release (`fleetbase/core-api ^1.6.69`).
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.68",
"version": "1.6.69",
"description": "Core Framework and Resources for Fleetbase API",
"keywords": [
"fleetbase",
Expand Down
14 changes: 14 additions & 0 deletions config/broadcasting.connections.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,21 @@
'port' => env('SOCKETCLUSTER_PORT', 8000),
'path' => env('SOCKETCLUSTER_PATH', '/socketcluster/'),
'query' => [],
// The websocket publisher sends no Origin of its own, and a socket server whose
// `origins` are restricted (as scripts/docker-install.sh sets them) rejects a
// handshake without one. Set SOCKETCLUSTER_ORIGIN to an allowed origin, e.g. the
// console URL.
'headers' => array_filter(['Origin' => env('SOCKETCLUSTER_ORIGIN')]),
],

// Realtime channel authentication. It is off unless SOCKETCLUSTER_AUTH_ENABLED is true
// and SOCKETCLUSTER_AUTH_KEY is set: until then no socket tokens are minted and
// broadcasts use the websocket publisher, so existing socket clients keep working.
// Turn it on once every client fetches socket tokens.
'auth_enabled' => Utils::castBoolean(env('SOCKETCLUSTER_AUTH_ENABLED', false)),
'auth_key' => env('SOCKETCLUSTER_AUTH_KEY'),
'publish_url' => env('SOCKETCLUSTER_PUBLISH_URL', 'http://' . env('SOCKETCLUSTER_HOST', 'socket') . ':8001'),
'token_ttl' => (int) env('SOCKETCLUSTER_TOKEN_TTL', 900),
],

// for apple apn
Expand Down
81 changes: 81 additions & 0 deletions config/database-backups.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

/*
* Environment defaults for database backups.
*
* A system administrator overrides any of these from the console (Admin → Database Backups);
* the override is stored as the `system.database-backups` setting. See
* Fleetbase\Support\DatabaseBackupSettings.
*/
return [
/*
* Whether scheduled backups run at all. Off by default so a fresh install does not
* start dumping its database somewhere before anyone has chosen where.
*/
'enabled' => env('DB_BACKUP_ENABLED', false),

/*
* hourly, every_six_hours, every_twelve_hours, daily or weekly. Times are UTC.
*/
'frequency' => env('DB_BACKUP_FREQUENCY', 'daily'),
'time' => env('DB_BACKUP_TIME', '00:00'),
'day_of_week' => (int) env('DB_BACKUP_DAY_OF_WEEK', 0),

/*
* Where dumps go: a disk from config/filesystems.php, an optional bucket override for
* s3 disks, and a key prefix.
*/
'disk' => env('DB_BACKUP_DISK', 's3'),
'bucket' => env('DB_BACKUP_BUCKET', 'fleetbase-db-backups'),
'path' => env('DB_BACKUP_PATH', ''),

/*
* The database connections to dump, by name.
*/
'connections' => array_values(array_filter(array_map('trim', explode(',', (string) env('DB_BACKUP_CONNECTIONS', 'mysql,sandbox'))))),

/*
* Retention, applied after each fully successful run. Null disables that limit.
*/
'retention_days' => env('DB_BACKUP_RETENTION_DAYS', 30),
'retention_count' => env('DB_BACKUP_RETENTION_COUNT'),

/*
* A compressed dump smaller than this many bytes fails the run. A gzip of nothing is
* 20 bytes; even an empty schema dump compresses to several hundred.
*/
'min_size_bytes' => (int) env('DB_BACKUP_MIN_SIZE_BYTES', 1024),

/*
* Who hears about a failed run.
*/
'notify_on_failure' => env('DB_BACKUP_NOTIFY_ON_FAILURE', false),
'notify_emails' => array_values(array_filter(array_map('trim', explode(',', (string) env('DB_BACKUP_NOTIFY_EMAILS', ''))))),

/*
* The dump client and the arguments it always gets. --single-transaction gives a
* consistent InnoDB snapshot without locking; --no-tablespaces avoids needing the
* PROCESS privilege, which managed databases such as RDS do not grant.
*/
'dump_binary' => env('DB_BACKUP_DUMP_BINARY', 'mysqldump'),
'dump_args' => [
'--single-transaction',
'--quick',
'--routines',
'--triggers',
'--hex-blob',
'--no-tablespaces',
'--default-character-set=utf8mb4',
],
'extra_dump_args' => array_values(array_filter(explode(' ', (string) env('DB_BACKUP_EXTRA_DUMP_ARGS', '')))),

/*
* Seconds a single database's dump may take.
*/
'timeout' => (int) env('DB_BACKUP_TIMEOUT', 7200),

/*
* Where dumps are written before upload.
*/
'tmp_dir' => env('DB_BACKUP_TMP_DIR', sys_get_temp_dir()),
];
63 changes: 0 additions & 63 deletions config/laravel-mysql-s3-backup.php

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
/**
* Files are looked up by subject (store media, product images, proofs of
* delivery). Without an index each lookup scanned the whole files table.
*/
public function up(): void
{
if ($this->indexExists('files', 'files_subject_uuid_index')) {
return;
}

Schema::table('files', function (Blueprint $table) {
$table->index('subject_uuid');
});
}

public function down(): void
{
if (!$this->indexExists('files', 'files_subject_uuid_index')) {
return;
}

Schema::table('files', function (Blueprint $table) {
$table->dropIndex(['subject_uuid']);
});
}

protected function indexExists(string $table, string $index): bool
{
try {
$indexes = Schema::getConnection()
->getDoctrineSchemaManager()
->listTableIndexes($table);

return isset($indexes[$index]);
} catch (Throwable $e) {
return false;
}
}
};
44 changes: 44 additions & 0 deletions migrations/2026_10_06_000000_create_database_backups_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
/**
* Run the migrations.
*
* One row per database per backup run, so an administrator can see what ran, how big it
* was, how long it took and why it failed — the old command wrote nothing anywhere and
* reported success while uploading empty dumps.
*/
public function up(): void
{
Schema::create('database_backups', function (Blueprint $table) {
$table->uuid('uuid')->primary();
$table->string('connection_name', 64);
$table->string('database', 128);
$table->string('status', 16)->index();
$table->string('trigger', 16);
$table->string('disk', 64);
$table->string('path', 512)->nullable();
$table->unsignedBigInteger('size_bytes')->nullable();
$table->unsignedBigInteger('duration_ms')->nullable();
$table->text('error')->nullable();
$table->timestamp('started_at')->index();
$table->timestamp('completed_at')->nullable();
$table->timestamp('pruned_at')->nullable();
$table->timestamps();

$table->index(['disk', 'path']);
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('database_backups');
}
};
Loading
Loading