diff --git a/CHANGELOG.md b/CHANGELOG.md index 54c095c..179d2f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- `$fleetbase->socket->token()` mints a short-lived realtime socket token (`POST socket/token`) for server-side exchange; the browser presents it with `socket.authenticate(token)`. + ## [1.4.1] - 2026-09-14 ### Changed diff --git a/README.md b/README.md index 5b82f4b..9f01624 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,24 @@ $history = $fleetbase->vehicles->listVehicleInspections($vehicleId, ['limit' => Read the form's `grouped_fields` to obtain field IDs and required answer types; a field's `id` is what an answer names, and it is the same id the submission answers with. `answers` was called `custom_field_values`, and each answer's `field` was called `custom_field`; both older spellings are still accepted on submit. Reuse the same caller-generated idempotency key when replaying one submission; generate a new key for a new inspection. The SDK passes the key through to the API and does not implement its own deduplication. Forms are published in the console, not created through this public API. Form authoring, submission updates/deletion, and public inspection-link management are not supported public endpoints. +### Realtime socket tokens + +Realtime channel subscriptions are authorized with a short-lived socket token. Mint it on your server with your secret key and send only the token to the browser or device; never expose the API key in client code. + +```php +$minted = $fleetbase->socket->token(); // POST /v1/socket/token + +// Return this to your authenticated front end: +// { "token": "...", "expires_in": 900, "expires_at": "2026-01-01T00:15:00+00:00" } +echo json_encode([ + 'token' => $minted->token, + 'expires_in' => $minted->expires_in, + 'expires_at' => $minted->expires_at, +]); +``` + +The browser connects with `socketcluster-client`, calls `socket.authenticate(token)` (or supplies the token through an in-memory `authEngine`), and asks your server for a new token about 60 seconds before `expires_in` elapses. A token minted with an API key may subscribe to its company channel (`company.{company uuid}`), its own key channel (`api.{key id}`), and channels of resources in the same company. A server without realtime authentication configured answers `404`, raised as `NotFoundException`. + ## Configuration The second constructor argument accepts client configuration. The third legacy argument retains the debug flag without printing requests or credentials. diff --git a/src/Fleetbase.php b/src/Fleetbase.php index bcb4f51..25762dd 100644 --- a/src/Fleetbase.php +++ b/src/Fleetbase.php @@ -42,6 +42,7 @@ use Fleetbase\Sdk\Services\ServiceAreaService; use Fleetbase\Sdk\Services\ServiceQuoteService; use Fleetbase\Sdk\Services\ServiceRateService; +use Fleetbase\Sdk\Services\SocketService; use Fleetbase\Sdk\Services\TrackingNumberService; use Fleetbase\Sdk\Services\TrackingStatusService; use Fleetbase\Sdk\Services\TrailerService; @@ -175,6 +176,9 @@ class Fleetbase /** @var FileService */ public $files; + /** @var SocketService */ + public $socket; + /** @param array $config */ public function __construct(string $publicKey, array $config = [], bool $debug = false) { @@ -221,6 +225,7 @@ public function __construct(string $publicKey, array $config = [], bool $debug = $this->chatChannels = new ChatChannelService($this->client); $this->comments = new CommentService($this->client); $this->files = new FileService($this->client); + $this->socket = new SocketService($this->client); } public function setApiKey(string $publicKey): Fleetbase @@ -460,4 +465,9 @@ public function workOrders(): WorkOrderService { return $this->workOrders; } + + public function socket(): SocketService + { + return $this->socket; + } } diff --git a/src/Services/SocketService.php b/src/Services/SocketService.php new file mode 100644 index 0000000..1736582 --- /dev/null +++ b/src/Services/SocketService.php @@ -0,0 +1,52 @@ + + * @license https://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0-or-later + */ + +declare(strict_types=1); + +namespace Fleetbase\Sdk\Services; + +use Fleetbase\Sdk\HttpClient; +use Fleetbase\Sdk\Service; + +/** + * Realtime (SocketCluster) helpers. + * + * Hand-written: this service is not generated from the Postman contract, so + * tools/generate-endpoint-services.php leaves it untouched. + */ +class SocketService extends Service +{ + /** @param array $options */ + public function __construct(HttpClient $client, array $options = []) + { + parent::__construct('Socket', $client, array_merge(['namespace' => 'socket'], $options)); + } + + /** + * Mint a short-lived realtime socket token: `POST socket/token`. + * + * Call this on your server with your secret API key and hand only the + * returned token to the browser or device, which presents it with + * `socket.authenticate(token)`. An API-key token may subscribe to the + * key's company channel (`company.{company uuid}`), its own key channel + * (`api.{key id}`), and channels of resources in the same company. + * Refresh it about 60 seconds before `expires_in` elapses. + * + * The decoded response has `token` (string), `expires_in` (seconds) and + * `expires_at` (ISO 8601). A server without realtime authentication + * configured responds 404, raised as a NotFoundException. + * + * @param array $options Request options. + * @return mixed + */ + public function token(array $options = []) + { + return $this->client->post($this->uri('token'), [], $options); + } +} diff --git a/tests/Fleetbase/FleetbaseTest.php b/tests/Fleetbase/FleetbaseTest.php index ad72af0..760e295 100644 --- a/tests/Fleetbase/FleetbaseTest.php +++ b/tests/Fleetbase/FleetbaseTest.php @@ -117,6 +117,7 @@ private static function services(): array 'chatChannels' => \Fleetbase\Sdk\Services\ChatChannelService::class, 'comments' => \Fleetbase\Sdk\Services\CommentService::class, 'files' => \Fleetbase\Sdk\Services\FileService::class, + 'socket' => \Fleetbase\Sdk\Services\SocketService::class, ]; } } diff --git a/tests/Fleetbase/SocketTest.php b/tests/Fleetbase/SocketTest.php new file mode 100644 index 0000000..c66a68e --- /dev/null +++ b/tests/Fleetbase/SocketTest.php @@ -0,0 +1,47 @@ +mockHttpClient([ + new Response(200, ['Content-Type' => 'application/json'], '{"token":"header.payload.signature","expires_in":900,"expires_at":"2026-01-01T00:15:00+00:00"}'), + ])); + + $minted = $service->token(); + + self::assertIsObject($minted); + $attributes = get_object_vars($minted); + self::assertSame('header.payload.signature', $attributes['token'] ?? null); + self::assertSame(900, $attributes['expires_in'] ?? null); + self::assertSame('2026-01-01T00:15:00+00:00', $attributes['expires_at'] ?? null); + + self::assertCount(1, $this->history); + $transaction = $this->history[0]; + self::assertIsArray($transaction); + $request = $transaction['request'] ?? null; + self::assertInstanceOf(RequestInterface::class, $request); + self::assertSame('POST', $request->getMethod()); + self::assertSame('/v1/socket/token', $request->getUri()->getPath()); + } + + public function testServerWithoutSocketAuthRaisesNotFound(): void + { + $service = new SocketService($this->mockHttpClient([ + new Response(404, ['Content-Type' => 'application/json'], '{"error":"Not found"}'), + ])); + + $this->expectException(NotFoundException::class); + $service->token(); + } +}