From 4c5212f4f7190b2975e7b7e770dfc75a9f77cc20 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 14:45:00 +0800 Subject: [PATCH] feat(socket-auth): add socket.token() to mint realtime socket tokens Adds a Socket helper on the client (fleetbase.socket.token()) that calls POST socket/token and resolves to { token, expires_in, expires_at }. Documents the server-side mint / browser authenticate flow. --- .changeset/socket-token.md | 5 +++ README.md | 64 ++++++++++++++++++++++++++++++++++++++ src/fleetbase.ts | 5 +++ src/index.ts | 4 ++- src/socket.ts | 35 +++++++++++++++++++++ tests/core.test.ts | 27 ++++++++++++++++ 6 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 .changeset/socket-token.md create mode 100644 src/socket.ts diff --git a/.changeset/socket-token.md b/.changeset/socket-token.md new file mode 100644 index 0000000..8fcf7ce --- /dev/null +++ b/.changeset/socket-token.md @@ -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. diff --git a/README.md b/README.md index 2a7df74..3e01bdf 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/fleetbase.ts b/src/fleetbase.ts index a58a2bc..2d56297 100644 --- a/src/fleetbase.ts +++ b/src/fleetbase.ts @@ -1,4 +1,5 @@ import { detectAdapter } from './adapters/detect.js'; +import Socket from './socket.js'; import Store from './store.js'; import { driverActions, @@ -117,6 +118,8 @@ export default class Fleetbase { fuelReports: Store; issues: Store; 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) { @@ -154,6 +157,7 @@ export default class Fleetbase { this.fuelReports = new Store('fuel-report', this.adapter); this.issues = new Store('issue', this.adapter); this.workOrders = new Store('work-order', this.adapter).extendActions(workOrderActions) as WorkOrderStore; + this.socket = new Socket(this.adapter); } static newInstance(...params: ConstructorParameters): Fleetbase { @@ -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; } diff --git a/src/index.ts b/src/index.ts index 1124872..4521281 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; @@ -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'; @@ -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'; diff --git a/src/socket.ts b/src/socket.ts new file mode 100644 index 0000000..4ae28fd --- /dev/null +++ b/src/socket.ts @@ -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 { + return this.adapter.post('socket/token', {}, options) as Promise; + } +} diff --git a/tests/core.test.ts b/tests/core.test.ts index c2c8561..068f18c 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -39,6 +39,7 @@ import Fleetbase, { ServiceArea, ServiceQuote, ServiceRate, + Socket, Store, StoreActions, TrackingStatus, @@ -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) {