Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/socket-token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fleetbase/sdk': minor
---

Add `fleetbase.socket.token()` (`POST socket/token`) to mint a short-lived realtime socket token server-side. It resolves to `{ token, expires_in, expires_at }` (`SocketTokenResponse`) and follows `setAdapter`. Additive; no existing export, store, or method changes.
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,70 @@ const place = new Place({

The root package exports the existing resource classes, adapters, collection helpers, resolver and registry hooks, string helpers, validation utilities, and TypeScript request/configuration types.

## Realtime socket tokens

Fleetbase publishes realtime events over SocketCluster. Channel subscriptions are authorized with a short-lived socket token, so the flow is always:

1. **On your server**, use your secret key to mint a token with `fleetbase.socket.token()` (`POST /v1/socket/token`).
2. Send **only the token** to the browser or device. Never send the API key.
3. In the browser, connect with `socketcluster-client`, present the token (`socket.authenticate(token)` or an in-memory `authEngine`), then subscribe.

```ts
// server.ts: runs on your backend, never in the browser
import Fleetbase from '@fleetbase/sdk';

const fleetbase = new Fleetbase(process.env.FLEETBASE_SECRET_KEY!);

app.post('/realtime-token', requireSignedInUser, async (_req, res) => {
// { token, expires_in, expires_at }
res.json(await fleetbase.socket.token());
});
```

```ts
// browser.ts
import { create } from 'socketcluster-client';

async function fetchSocketToken(): Promise<{ token: string; expires_in: number }> {
const response = await fetch('/realtime-token', { method: 'POST', credentials: 'include' });
if (!response.ok) throw new Error(`Token request failed: ${response.status}`);
return response.json();
}

// Keep the token in memory only. SocketCluster calls loadToken() before every (re)connect,
// so the token travels in the handshake and subscriptions are authorized from the start.
let current: { token: string; refreshAt: number } | null = null;
const remember = ({ token, expires_in }: { token: string; expires_in: number }) => {
current = { token, refreshAt: Date.now() + (expires_in - 60) * 1000 };
return token;
};
const authEngine = {
saveToken: async (_name: string, token: string) => token,
removeToken: async () => {
const token = current?.token ?? null;
current = null;
return token;
},
loadToken: async () => (current && Date.now() < current.refreshAt ? current.token : remember(await fetchSocketToken())),
};

const socket = create({ hostname: 'socket.example.com', secure: true, port: 443, authEngine });

// Refresh about 60 seconds before expiry without dropping subscriptions.
setInterval(async () => {
if (current && Date.now() >= current.refreshAt) {
await socket.authenticate(remember(await fetchSocketToken()));
}
}, 15_000);

const channel = socket.subscribe(`company.${companyUuid}`);
for await (const event of channel) {
console.log(event);
}
```

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 that belong to the same company (for example `order.{order uuid or public id}`). A server that does not have realtime authentication configured answers the mint request with `404`; in that case connect without a token as before.

## Custom adapters

Implement the stable adapter interface when requests need to use an application-specific transport:
Expand Down
5 changes: 5 additions & 0 deletions src/fleetbase.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { detectAdapter } from './adapters/detect.js';
import Socket from './socket.js';
import Store from './store.js';
import {
driverActions,
Expand Down Expand Up @@ -117,6 +118,8 @@ export default class Fleetbase {
fuelReports: Store<FuelReport>;
issues: Store<Issue>;
workOrders: WorkOrderStore;
/** Realtime helpers, e.g. `socket.token()` to mint a short-lived socket token server-side. */
socket: Socket;

constructor(publicKey: string, config: FleetbaseConfig = {}, debug = false) {
if (typeof publicKey !== 'string' || publicKey.length === 0) {
Expand Down Expand Up @@ -154,6 +157,7 @@ export default class Fleetbase {
this.fuelReports = new Store<FuelReport>('fuel-report', this.adapter);
this.issues = new Store<Issue>('issue', this.adapter);
this.workOrders = new Store<WorkOrder>('work-order', this.adapter).extendActions(workOrderActions) as WorkOrderStore;
this.socket = new Socket(this.adapter);
}

static newInstance(...params: ConstructorParameters<typeof Fleetbase>): Fleetbase {
Expand All @@ -162,6 +166,7 @@ export default class Fleetbase {

setAdapter(adapter: AdapterLike): void {
this.adapter = adapter;
this.socket.adapter = adapter;
for (const store of this.stores()) {
store.adapter = adapter;
}
Expand Down
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import EmberJsAdapter from './adapters/ember.js';
import NodeAdapter from './adapters/node.js';
import Fleetbase from './fleetbase.js';
import Resource from './resource.js';
import Socket from './socket.js';
import Store from './store.js';
import { register } from './registry.js';

Expand All @@ -13,7 +14,7 @@ register('adapter', 'NodeAdapter', NodeAdapter);
register('adapter', 'EmberJsAdapter', EmberJsAdapter);

export default Fleetbase;
export { Fleetbase, Adapter, BrowserAdapter, EmberJsAdapter, NodeAdapter, Resource, Store };
export { Fleetbase, Adapter, BrowserAdapter, EmberJsAdapter, NodeAdapter, Resource, Socket, Store };
export { detectAdapter } from './adapters/detect.js';
export { default as Collection, createCollection, isCollection, iter, objectAt, replace, uniqBy } from './collection.js';
export { FleetbaseError } from './errors.js';
Expand Down Expand Up @@ -46,5 +47,6 @@ export {
} from './utils.js';
export { isResource } from './resource.js';
export type * from './types.js';
export type { SocketTokenResponse } from './socket.js';

export type { DriverStore, ManifestStore, ManifestStopStore, OrderStore, OrganizationStore, ServiceQuoteStore, TrailerStore, VehicleStore, WorkOrderStore } from './fleetbase.js';
35 changes: 35 additions & 0 deletions src/socket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { AdapterLike, RequestOptions } from './types.js';

/** Response of every Fleetbase socket token mint route. */
export interface SocketTokenResponse {
/** Short-lived HS256 JWT to hand to the realtime client (`socket.authenticate(token)`). */
token: string;
/** Lifetime of the token in seconds. Refresh about 60 seconds before it elapses. */
expires_in: number;
/** Expiry as an ISO 8601 timestamp. */
expires_at: string;
}

/**
* Realtime (SocketCluster) helpers.
*
* Mint socket tokens on a server with a secret key, then pass only the short-lived token
* to the browser or device that opens the realtime connection.
*/
export default class Socket {
adapter: AdapterLike;

constructor(adapter: AdapterLike) {
this.adapter = adapter;
}

/**
* `POST socket/token` — mint a short-lived realtime token for the authenticating credential.
* For an API key the token authorizes subscriptions to the key's company channel
* (`company.{company uuid}`), its own `api.{key id}` channel, and resource channels of the
* same company. For a driver's user token it is scoped to that driver's own channels.
*/
token(options: RequestOptions = {}): Promise<SocketTokenResponse> {
return this.adapter.post('socket/token', {}, options) as Promise<SocketTokenResponse>;
}
}
27 changes: 27 additions & 0 deletions tests/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import Fleetbase, {
ServiceArea,
ServiceQuote,
ServiceRate,
Socket,
Store,
StoreActions,
TrackingStatus,
Expand Down Expand Up @@ -673,6 +674,32 @@ describe('driver app stores', () => {
});
});

describe('realtime socket tokens', () => {
it('mints a socket token with POST socket/token and follows adapter replacement', async () => {
const adapter = new RecordingAdapter();
const minted = { token: 'jwt', expires_in: 900, expires_at: '2026-01-01T00:15:00.000Z' };
adapter.response = minted;
const sdk = new Fleetbase('pk_test', { adapter });
expect(sdk.socket).toBeInstanceOf(Socket);
expect(sdk.socket.adapter).toBe(adapter);
await expect(sdk.socket.token()).resolves.toEqual(minted);
expect(adapter.calls.at(-1)).toEqual(['POST', 'socket/token', {}, {}]);
await sdk.socket.token({ headers: { 'X-Request-Id': 'abc' } });
expect(adapter.calls.at(-1)).toEqual(['POST', 'socket/token', {}, { headers: { 'X-Request-Id': 'abc' } }]);
const replacement = new RecordingAdapter();
sdk.setAdapter(replacement);
expect(sdk.socket.adapter).toBe(replacement);
await sdk.socket.token();
expect(replacement.calls).toHaveLength(1);
});

it('propagates mint failures such as a 404 from a server without socket auth', async () => {
const adapter = new RecordingAdapter();
adapter.failure = new Error('Not Found');
await expect(new Socket(adapter).token()).rejects.toThrow('Not Found');
});
});

describe('published v1 compatibility snapshot', () => {
it('preserves every published root export', () => {
for (const name of contract.exports) {
Expand Down
Loading