From e6e7b5d27fa126b48a58766d3757ecde28a140bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 02:51:44 +0000 Subject: [PATCH] Support private-encrypted- channels with a Pusher prop on HibiraProvider The default browser build of pusher-js has no NaCl, so encrypted channels failed unless you built and passed your own client. Pass the class from pusher-js/with-encryption instead. Adds a README section, a unit test, a type test and an end-to-end test against a real server. Release 0.3.0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SHtsCF9LUzGnmMxYox3ZKU --- CHANGELOG.md | 8 ++++++++ README.md | 18 ++++++++++++++++++ index.d.ts | 5 +++++ index.js | 9 ++++++--- package-lock.json | 4 ++-- package.json | 2 +- test/e2e/hooks.e2e.mjs | 32 ++++++++++++++++++++++++++++++++ test/react.test.mjs | 19 +++++++++++++++++++ test/types.test.tsx | 2 ++ 9 files changed, 93 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1598e10..5eeb036 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.3.0 + +- `HibiraProvider` takes a `Pusher` prop, the pusher-js class to open the + connection with. Pass the one from `pusher-js/with-encryption` to use + end-to-end encrypted `private-encrypted-` channels; the default browser + build of pusher-js cannot decrypt them. README: new "Encrypted channels" + section. + ## 0.2.1 - README: cover image and screenshots. diff --git a/README.md b/README.md index 40c5ddf..085f068 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,24 @@ environment then. On the Free plan, add your site's origin (for example `http://localhost:3000`) to the app's allowed origins, or browsers can't connect. +## Encrypted channels + +On `private-encrypted-` channels your backend encrypts each event and only +subscribers can read it; Hibira relays ciphertext it cannot decrypt. Open the +connection with the pusher-js build that includes encryption: + +```jsx +import Pusher from 'pusher-js/with-encryption'; + + +``` + +On the backend, give the `pusher` package a 32-byte key (`openssl rand -base64 32`) +as `encryptionMasterKeyBase64`. It then encrypts what you trigger on these +channels, and `authorizeChannel` returns the channel's key to the subscriber. +`useEvent` and `useStream` get the decrypted data as usual. Client events +(`useTrigger`) are not allowed on encrypted channels. + ## Streaming LLM output Send the model's stream from your backend with `streamToChannel()`. It diff --git a/index.d.ts b/index.d.ts index 8fe4827..aa892f9 100644 --- a/index.d.ts +++ b/index.d.ts @@ -26,6 +26,11 @@ export interface HibiraProviderProps { options?: Partial; /** Use this client instead of opening one, e.g. `echo.connector.pusher`. Not disconnected on unmount. */ client?: Pusher | null; + /** + * The pusher-js class to open the connection with. Pass the one from + * `pusher-js/with-encryption` to use `private-encrypted-` channels. + */ + Pusher?: new (key: string, options: Options) => Pusher; children?: ReactNode; } diff --git a/index.js b/index.js index e48afc9..507027c 100644 --- a/index.js +++ b/index.js @@ -9,7 +9,7 @@ // null and the other hooks return their empty values. import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import Pusher from 'pusher-js'; +import DefaultPusher from 'pusher-js'; import { acquireChannel, hibiraOptions, isClientEvent, presenceMembers, releaseChannel } from './client.js'; import { STREAM_EVENT, createStreamStore, followStream, latestStream } from './stream.js'; import { AGENT_EVENTS, createAgentStore, latestRun } from './agent.js'; @@ -29,8 +29,11 @@ const HibiraContext = createContext(null); * (channelAuthorization, userAuthentication, wsHost for a local server...) * @param {object} [props.client] an existing pusher-js client to use instead, * e.g. `echo.connector.pusher`; it is not disconnected on unmount + * @param {Function} [props.Pusher] the pusher-js class to open the connection + * with; pass the one from `pusher-js/with-encryption` for + * `private-encrypted-` channels */ -export function HibiraProvider({ appKey, options, client, children }) { +export function HibiraProvider({ appKey, options, client, Pusher = DefaultPusher, children }) { const [own, setOwn] = useState(null); const optionsRef = useRef(options); optionsRef.current = options; @@ -43,7 +46,7 @@ export function HibiraProvider({ appKey, options, client, children }) { pusher.disconnect(); setOwn(null); }; - }, [client, appKey]); + }, [client, appKey, Pusher]); return createElement(HibiraContext.Provider, { value: client ?? own }, children); } diff --git a/package-lock.json b/package-lock.json index a04e15b..1411de1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@hibira-io/react", - "version": "0.2.1", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@hibira-io/react", - "version": "0.2.1", + "version": "0.3.0", "license": "MIT", "devDependencies": { "@types/react": "^19.3.0", diff --git a/package.json b/package.json index 68add19..4aa059a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hibira-io/react", - "version": "0.2.1", + "version": "0.3.0", "description": "React hooks for pusher-js on Hibira and other Pusher-compatible servers: shared channels, events, presence, connection state, client events, and LLM token streaming with streamToChannel", "keywords": [ "react", diff --git a/test/e2e/hooks.e2e.mjs b/test/e2e/hooks.e2e.mjs index 3f93fe2..8e8fcb3 100644 --- a/test/e2e/hooks.e2e.mjs +++ b/test/e2e/hooks.e2e.mjs @@ -204,3 +204,35 @@ test('useStream rewind: chunks sent while disconnected are caught up', { skip: s assert.equal(views.s.text, res.text); await act(async () => root.unmount()); }); + +test('private-encrypted- channel: events arrive decrypted with Pusher from pusher-js/with-encryption', { skip }, async () => { + globalThis.self ??= globalThis; // the web build expects a browser global + const EncryptedPusher = require('pusher-js/with-encryption'); // bundlers resolve the bare import + const masterKey = Buffer.alloc(32, 7).toString('base64'); + const secure = new PusherServer({ + appId: env.HIBIRA_E2E_APP_ID, key: env.HIBIRA_E2E_KEY, secret: env.HIBIRA_E2E_SECRET, + host, port: String(port), useTLS: tls, encryptionMasterKeyBase64: masterKey, + }); + const channel = `private-encrypted-e2e-${Date.now()}`; + const opts = { + ...options('u', 'U'), + channelAuthorization: { + customHandler: ({ socketId, channelName }, cb) => cb(null, secure.authorizeChannel(socketId, channelName)), + }, + }; + const got = []; + const views = {}; + function View() { + views.pusher = usePusher(); + useEvent(channel, 'secret', (data) => got.push(data)); + return null; + } + const el = document.createElement('div'); + const root = createRoot(el); + await act(async () => root.render(h(HibiraProvider, { appKey: env.HIBIRA_E2E_KEY, options: opts, Pusher: EncryptedPusher }, h(View)))); + await waitFor(() => views.pusher?.channel(channel)?.subscribed, 'encrypted subscription'); + await secure.trigger(channel, 'secret', { diagnosis: 'fine', n: 1 }); + await waitFor(() => got.length === 1, 'decrypted event'); + assert.deepEqual(got[0], { diagnosis: 'fine', n: 1 }); + await act(async () => root.unmount()); +}); diff --git a/test/react.test.mjs b/test/react.test.mjs index 3eb7e96..ae39f02 100644 --- a/test/react.test.mjs +++ b/test/react.test.mjs @@ -330,3 +330,22 @@ test('a provider switched to another client resubscribes', async () => { assert.equal(out.value, p2.channel('c')); assert.equal(p1.channel('c'), undefined); }); + +test('HibiraProvider opens the connection with the Pusher class it is given', async () => { + const made = []; + class Custom { + constructor(key, options) { + made.push([key, options.wsHost]); + this.disconnected = false; + } + disconnect() { + this.disconnected = true; + } + } + const out = {}; + const r = await render(h(HibiraProvider, { appKey: 'k', Pusher: Custom }, h(probe(() => usePusher(), out)))); + assert.deepEqual(made, [['k', 'ws.hibira.io']]); + assert.ok(out.value instanceof Custom); + await r.unmount(); + assert.equal(out.value.disconnected, true); +}); diff --git a/test/types.test.tsx b/test/types.test.tsx index f5d3f04..1b035e5 100644 --- a/test/types.test.tsx +++ b/test/types.test.tsx @@ -1,5 +1,6 @@ // Compiled by `npm run typecheck`, never run. import Pusher from 'pusher-js'; +import EncryptedPusher from 'pusher-js/with-encryption'; import { HibiraProvider, useAgentStream, useChannel, useEvent, usePresence, useStream, useTrigger, useConnectionState, hibiraOptions } from '@hibira-io/react'; import { streamToChannel } from '@hibira-io/react/server'; @@ -40,3 +41,4 @@ export async function server(pusher: { trigger(c: string, e: string, d: any, x?: const r = await streamToChannel(pusher, 'private-ai', gen(), { meta: { model: 'm' }, interval: 50 }); return r.id + r.text; } +export const encryptedProvider = ;