From 4247928c45a8352ca5ee266c18ef9cdff23815a6 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 19 Aug 2026 22:59:04 +0200 Subject: [PATCH 1/6] feat: sqlcipher support initial impl --- examples/SampleApp/App.tsx | 120 +++++++- examples/SampleApp/ios/Podfile.lock | 16 +- examples/SampleApp/package.json | 3 + package/src/components/Chat/Chat.tsx | 128 ++++++++- .../components/Chat/__tests__/Chat.test.tsx | 239 +++++++++++++++- package/src/index.ts | 2 +- package/src/mock-builders/DB/mock.ts | 13 + package/src/store/OfflineDB.ts | 34 ++- package/src/store/SqliteClient.ts | 183 ++++++++++++- .../src/store/__tests__/SqliteClient.test.ts | 258 ++++++++++++++++++ 10 files changed, 963 insertions(+), 33 deletions(-) create mode 100644 package/src/store/__tests__/SqliteClient.test.ts diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index 00fc913a3a..efb6384414 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -20,6 +20,8 @@ import { OverlayProvider, setupCommandUIMiddlewares, SqliteClient, + SqliteClientError, + type SqliteClientErrorCode, Streami18n, ThemeProvider, useOverlayContext, @@ -330,24 +332,118 @@ const DrawerNavigator: React.FC = () => ( const isMessageAIGenerated = (message: LocalMessage) => !!message.ai_generated; +/** + * Demonstrates encryption-at-rest for the offline database. Paired with + * `{ "op-sqlite": { "sqlcipher": true } }` in this app's package.json, which is what + * actually builds op-sqlite against SQLCipher - without it the SDK refuses to open + * the database rather than write it in plaintext. + * + * A hardcoded constant is fine for a sample and wrong for a real app: it ships the + * key inside the binary, so anyone who can read the database can also read the key. + * A real integration reads the key from the iOS Keychain / Android Keystore, and + * rotates a key-encryption key around a stable database key (envelope encryption) so + * rotation does not force the cache to be wiped. + * + * What this does illustrate is the invariant that matters: the same key comes back on + * every launch. A key that changes wipes the offline cache and rebuilds it from the + * server. + */ +const getEncryptionKey = () => Promise.resolve('sample-app-offline-db-key'); + +/** + * `` throws a {@link SqliteClientError} from render when it cannot open the + * offline database with the encryption that was asked for. It never downgrades to + * running unencrypted on its own - recovery is the application's decision. + * + * This boundary shows the two sensible responses: + * + * - `OFFLINE_DB_UNREADABLE` - the file exists but this key cannot read it (the key + * changed, or it predates encryption). The data is a cache, so delete it and retry; + * the only real loss is actions that were queued while offline. A real app may want + * to confirm with the user first. + * - `SQLCIPHER_BUILD_MISSING` / `ENCRYPTION_KEY_UNAVAILABLE` - there is no usable key, + * so a new database would be written in plaintext. Run online-only instead: no local + * cache means nothing unencrypted on disk. + */ +type BoundaryProps = React.PropsWithChildren<{ + onGiveUp: () => void; + onRetry: () => void; +}>; + +type BoundaryState = { code?: SqliteClientErrorCode }; + +class OfflineEncryptionBoundary extends React.Component { + state: BoundaryState = {}; + + // Must return state, and render() must stop rendering the failing subtree. Returning + // null here would re-render the same children, they would throw again, and React + // would give up and unmount the whole app. + static getDerivedStateFromError(error: unknown) { + const code = (error as SqliteClientError | undefined)?.code; + if (!code) { + throw error; + } + return { code }; + } + + componentDidCatch(error: unknown) { + // Discriminated on `code` rather than `instanceof`: a string comparison cannot be + // defeated by two copies of the class ending up in one bundle. + const code = (error as SqliteClientError | undefined)?.code; + + if (code === 'OFFLINE_DB_UNREADABLE') { + // The recommended recovery: the contents are a cache, so drop the database and + // let it rebuild. Only actions queued while offline are lost. + try { + SqliteClient.deleteDatabase(); + } catch (deleteError) { + console.warn('[SampleApp] could not delete the offline database', deleteError); + } + this.props.onRetry(); + return; + } + + // No usable key, so a new database would be plaintext. Run online-only instead. + console.warn(`[SampleApp] offline encryption unavailable (${code}); going online-only`); + this.props.onGiveUp(); + } + + render() { + return this.state.code ? null : this.props.children; + } +} + const DrawerNavigatorWrapper: React.FC<{ chatClient: StreamChat; i18nInstance: Streami18n; }> = ({ chatClient, i18nInstance }) => { + // `attempt` re-mounts after the offline database has been deleted; + // `offlineSupport` is switched off once there is no usable encryption key. + const [attempt, setAttempt] = useState(0); + const [offlineSupport, setOfflineSupport] = useState(true); + return ( - setOfflineSupport(false)} + onRetry={() => setAttempt((value) => value + 1)} > - - - - - - + + + + + + + + ); }; diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 34584cc346..3122bf4153 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -233,6 +233,7 @@ PODS: - Yoga - op-sqlite (17.1.2): - hermes-engine + - OpenSSL-Universal - RCTRequired - RCTTypeSafety - React-Core @@ -253,6 +254,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga + - OpenSSL-Universal (3.6.2000) - PromisesObjC (2.4.1) - PromisesSwift (2.4.1): - PromisesObjC (= 2.4.1) @@ -298,7 +300,7 @@ PODS: - React-utils - ReactNativeDependencies - Yoga - - React-Core-prebuilt (0.86.0): + - React-Core-prebuilt (0.86.2): - ReactNativeDependencies - React-Core/CoreModulesHeaders (0.86.2): - hermes-engine @@ -2857,7 +2859,7 @@ PODS: - SDWebImageWebPCoder (0.15.0): - libwebp (~> 1.0) - SDWebImage/Core (~> 5.17) - - stream-chat-react-native (9.7.2): + - stream-chat-react-native (9.7.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3057,6 +3059,7 @@ SPEC REPOS: - libdav1d - libwebp - nanopb + - OpenSSL-Universal - PromisesObjC - PromisesSwift - SDWebImage @@ -3291,14 +3294,15 @@ SPEC CHECKSUMS: GoogleAppMeasurement: 57270ccc2b77472d7e85c4cbe45972564eff78bb GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 766ace00c6b10d8148408f329d10c4f051931850 - hermes-engine: 188393eb43a0cce2dfbf912e6d22c7bb6469957d + hermes-engine: 3730f5b467f988fa954ded67cbea8a9ba32d854c libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 NitroModules: e0ac5f9a04e23cb2f378b51810ebc07ed63aeae9 NitroSound: a18e2d59d0d60c291586e622ce4752c53da73086 - op-sqlite: d8d5eae2bddb0b55d6f48cf7ac356b63d26cb4f0 + op-sqlite: 6cf4cf717567180707bf372c894b27f9bce04a51 + OpenSSL-Universal: ecee7b138fa75a74ecf00d7ffd248fb584739b9e PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273 PromisesSwift: 217dea0fd5d2ad65222a109c48698add13cc1c5b RCTDeprecation: bccb6545c26db881ecddfd83a3f9ea82aba1605f @@ -3309,7 +3313,7 @@ SPEC CHECKSUMS: React: 4b2532a459d15e1adf6c22d3e399e5c85a94220f React-callinvoker: 0b8ce4057e02a0bd15cf0532596e8eb8c0392e92 React-Core: 5af045531a540ba3f65f07de1e3f585ddfb27948 - React-Core-prebuilt: 13924a267683b3d6fa4bde9c80380becf83a9c5c + React-Core-prebuilt: 405cf395d66cf694faf9aed3483a21b5515cec85 React-CoreModules: 99b194a721de84ccfc1be149a0de52647dc38c0e React-cxxreact: b7e8e254074fd8111d147202b391ccf7816946a6 React-debug: 3281bfefe5ece9a9d8b28bec3f871db229f9d8d8 @@ -3399,7 +3403,7 @@ SPEC CHECKSUMS: SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377 - stream-chat-react-native: e97f6d3ed0c2828b20610ffc0023ad7f9c90738d + stream-chat-react-native: ea3499916019c499ecb745be61e7ecf82f0fd999 Teleport: c56b30b08bd20d10da1efb0f21c6bc50c269ee6a Yoga: 542a30dafe5b0f5f1d9f185ea7b2a3811ac54801 diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 1fcd0fa999..ec8a0316fc 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -2,6 +2,9 @@ "name": "sampleapp", "version": "4.14.7", "private": true, + "op-sqlite": { + "sqlcipher": true + }, "repository": { "type": "git", "url": "https://github.com/GetStream/stream-chat-react-native.git" diff --git a/package/src/components/Chat/Chat.tsx b/package/src/components/Chat/Chat.tsx index 7eb6228cf3..3614347381 100644 --- a/package/src/components/Chat/Chat.tsx +++ b/package/src/components/Chat/Chat.tsx @@ -1,4 +1,4 @@ -import React, { PropsWithChildren, useEffect, useMemo, useState } from 'react'; +import React, { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Platform } from 'react-native'; import { Channel, OfflineDBState } from 'stream-chat'; @@ -25,6 +25,7 @@ import init from '../../init'; import { NativeHandlers } from '../../native'; import { DEFAULT_MAX_SYNC_EVENTS_LIMIT } from '../../store/constants'; import { OfflineDB } from '../../store/OfflineDB'; +import { SqliteClient, SqliteClientError } from '../../store/SqliteClient'; import type { Streami18n } from '../../utils/i18n/Streami18n'; import { installNativeMultipartAdapter } from '../../utils/installNativeMultipartAdapter'; @@ -43,8 +44,62 @@ export type ChatProps = Pick & closeConnectionOnBackground?: boolean; /** * Enables offline storage and loading for chat data. + * + * **Wrap `` in an error boundary.** If the database on disk cannot be read - + * corruption, or an encrypted database left behind after {@link getEncryptionKey} + * was removed - `` throws a {@link SqliteClientError} with code + * `OFFLINE_DB_UNREADABLE` from render. The SDK never deletes it for you; recover + * with `SqliteClient.deleteDatabase()` and re-mount, which rebuilds from the + * server. Prior to this the same situation left offline support uninitialized and + * `` rendering `ChatLoadingIndicator` indefinitely, with no way to react. */ enableOfflineSupport?: boolean; + /** + * Encrypts the offline database at rest with SQLCipher, using the key this + * resolves to. Only relevant when `enableOfflineSupport` is enabled. Leaving it + * unset keeps the offline database unencrypted, which is the default. + * + * Requires a native build of `@op-engineering/op-sqlite` that includes SQLCipher. + * Add the following to your application's `package.json` and rebuild the native + * app - without the flag the key is accepted and then silently ignored: + * + * ```json + * { "op-sqlite": { "sqlcipher": true } } + * ``` + * + * **Wrap `` in an error boundary.** If the database cannot be opened with + * the encryption you asked for, `` throws a {@link SqliteClientError} + * from render instead of continuing without it. The SDK deliberately takes no + * recovery action of its own - it never deletes data, and never silently falls + * back to an unencrypted or absent cache. Discriminate on `code`: + * + * - `OFFLINE_DB_UNREADABLE` - the file exists but this key cannot read it (the + * key changed, or the database predates encryption). **Recommended recovery: + * `SqliteClient.deleteDatabase()`, then re-mount ``.** The contents are a + * cache and are refetched from the server; the exception is actions queued while + * offline, which are lost - prompt the user first if that matters to you. + * - `ENCRYPTION_KEY_UNAVAILABLE` - the key could not be read (a locked keychain, a + * launch before first unlock). The database is untouched. **Recommended + * recovery: re-mount to retry** once the key is readable - for example when the + * app next returns to the foreground. + * - `SQLCIPHER_BUILD_MISSING` - the native build has no SQLCipher, so the key + * would be ignored and the database written in plaintext. Not recoverable at + * runtime; it needs the build flag above and a new binary. **Recommended + * recovery: re-mount with `enableOfflineSupport={false}`** so nothing is + * persisted unencrypted. + * + * `examples/SampleApp` implements all three. + * + * The key must be **stable for the lifetime of the database file**. There is no + * rekey path, so a key that changes costs one `OFFLINE_DB_UNREADABLE` and a + * rebuild. To rotate without paying that, rotate a key-encryption key and keep the + * database key it protects unchanged (envelope encryption). + * + * Switching encryption on, or back off, leaves a database from the other mode on + * disk and so raises `OFFLINE_DB_UNREADABLE` once in each direction. Deleting it + * from your boundary is all that is needed. + */ + getEncryptionKey?: () => Promise; /** * Optional positive cap on the number of events a single `/sync` response may * contain before the offline sync manager skips replaying those events into @@ -172,6 +227,7 @@ const ChatWithContext = (props: PropsWithChildren) => { client, closeConnectionOnBackground = true, enableOfflineSupport = false, + getEncryptionKey, i18nInstance, isMessageAIGenerated, maxSyncEventsLimit = DEFAULT_MAX_SYNC_EVENTS_LIMIT, @@ -181,6 +237,15 @@ const ChatWithContext = (props: PropsWithChildren) => { const { ChatLoadingIndicator } = useComponentsContext(); const [channel, setChannel] = useState(); + /** + * Why this mount could not open the offline database, or `undefined` if it did. + * + * Captured per attempt rather than observed from a longer-lived source: a value that + * outlives the attempt would be re-read during the first render after a re-mount and + * throw before that mount's own attempt could run, so an error boundary that + * re-mounts to retry would loop forever. + */ + const [initializationError, setInitializationError] = useState(); // Setup translators const translators = useStreami18n(i18nInstance); @@ -241,23 +306,57 @@ const ChatWithContext = (props: PropsWithChildren) => { const setActiveChannel = (newChannel?: Channel) => setChannel(newChannel); - useEffect(() => { + const getEncryptionKeyRef = useRef(getEncryptionKey); + getEncryptionKeyRef.current = getEncryptionKey; + const isEncryptionEnabled = !!getEncryptionKey; + + const initializeDatabase = useCallback(async () => { if (!(userID && enableOfflineSupport)) { return; } - const initializeDatabase = async () => { - if (!client.offlineDb) { - client.setOfflineDBApi(new OfflineDB({ client, maxSyncEventsLimit })); + if (!client.offlineDb) { + const getKey = isEncryptionEnabled + ? () => getEncryptionKeyRef.current?.() ?? Promise.resolve(undefined) + : undefined; + + // Confirm the database can be opened before attaching it: the client writes + // through `client.offlineDb` without checking that it initialized, so one we + // cannot open turns those writes into rejections (the channel list never + // loads). With nothing attached they take their online path. + if (getKey) { + SqliteClient.getEncryptionKey = getKey; + try { + await SqliteClient.preflightEncryption(); + } catch (error) { + if (error instanceof SqliteClientError) { + // Surfaced by re-throwing during render; see below. + setInitializationError(error); + return; + } + throw error; + } } - if (client.offlineDb) { - await client.offlineDb.init(userID); - } - }; + client.setOfflineDBApi( + new OfflineDB({ client, getEncryptionKey: getKey, maxSyncEventsLimit }), + ); + } + const { offlineDb } = client; + if (offlineDb) { + await offlineDb.init(userID); + // `init` never re-throws, so the reason is read back off the instance, which + // recorded it on the way out. + setInitializationError( + offlineDb instanceof OfflineDB ? offlineDb.initializationError : undefined, + ); + } + }, [userID, enableOfflineSupport, client, maxSyncEventsLimit, isEncryptionEnabled]); + + useEffect(() => { initializeDatabase(); - }, [userID, enableOfflineSupport, client, maxSyncEventsLimit]); + }, [initializeDatabase]); useEffect(() => { if (!client) { @@ -301,6 +400,15 @@ const ChatWithContext = (props: PropsWithChildren) => { setActiveChannel, }); + // Encryption is a security posture, not something to quietly downgrade. Rather than + // continuing without the encrypted cache the caller asked for, the failure is raised + // here so an error boundary above `` can decide what to do - re-mount with + // `enableOfflineSupport={false}`, wipe the database and retry, or surface it to the + // user. Thrown from render because a boundary cannot catch an async rejection. + if (initializationError) { + throw initializationError; + } + if (userID && enableOfflineSupport && !initialisedDatabase) { // if user id has been set and offline support is enabled, we need to wait for database to be initialised return ChatLoadingIndicator ? : null; diff --git a/package/src/components/Chat/__tests__/Chat.test.tsx b/package/src/components/Chat/__tests__/Chat.test.tsx index 4d6a43ad29..c6074f43b2 100644 --- a/package/src/components/Chat/__tests__/Chat.test.tsx +++ b/package/src/components/Chat/__tests__/Chat.test.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import { View } from 'react-native'; import NetInfo from '@react-native-community/netinfo'; @@ -9,10 +9,12 @@ import { useChatContext } from '../../../contexts/chatContext/ChatContext'; import type { TranslationContextValue } from '../../../contexts/translationContext/TranslationContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { sqliteMock } from '../../../mock-builders/DB/mock'; import dispatchConnectionChangedEvent from '../../../mock-builders/event/connectionChanged'; import dispatchConnectionRecoveredEvent from '../../../mock-builders/event/connectionRecovered'; import { getTestClient, getTestClientWithUser, setUser } from '../../../mock-builders/mock'; import { DEFAULT_MAX_SYNC_EVENTS_LIMIT } from '../../../store/constants'; +import { SqliteClient, SqliteClientError } from '../../../store/SqliteClient'; import { Streami18n } from '../../../utils/i18n/Streami18n'; import { Chat } from '../Chat'; @@ -368,3 +370,238 @@ describe('TranslationContext', () => { expect(chatClientWithUser.offlineDb!.syncManager.syncMaxEventCount).toBeUndefined(); }); }); + +describe('Chat offline DB encryption', () => { + const installedSpies: jest.SpyInstance[] = []; + + /** + * Registers a spy for teardown. Deliberately not jest.restoreAllMocks(): that also + * restores the connection privates mockClient() stubs out on every client created + * by earlier tests in this file, after which those clients reconnect for real and + * the failed websocket handshake resurfaces as an unhandled error somewhere else. + */ + const track = (spy: T): T => { + installedSpies.push(spy); + return spy; + }; + + /** + * Chat mounts useIsOnline, which opens the websocket whenever the app comes to the + * foreground. Left real, that connection attempt outlives the test and rejects + * asynchronously. Nothing in this block needs a connection. + */ + const createClient = async () => { + const client = await getTestClientWithUser({ id: 'testID' }); + track(jest.spyOn(client, 'openConnection').mockResolvedValue(undefined)); + track(jest.spyOn(client, 'closeConnection').mockResolvedValue(undefined)); + return client; + }; + + /** Minimal error boundary, since `` reports encryption failures by throwing. */ + class Boundary extends React.Component< + PropsWithChildren<{ onCatch: (error: Error) => void }>, + { caught: boolean } + > { + state = { caught: false }; + + static getDerivedStateFromError() { + return { caught: true }; + } + + componentDidCatch(error: Error) { + this.props.onCatch(error); + } + + render() { + return this.state.caught ? : this.props.children; + } + } + + afterEach(() => { + cleanup(); + installedSpies.splice(0).forEach((spy) => spy.mockRestore()); + SqliteClient.getEncryptionKey = undefined; + }); + + it('does not configure an encryption key when the prop is omitted', async () => { + const chatClientWithUser = await createClient(); + + render(); + + await waitFor(() => expect(chatClientWithUser.offlineDb).toBeDefined()); + expect(SqliteClient.getEncryptionKey).toBeUndefined(); + }); + + it('forwards getEncryptionKey to the sqlite client', async () => { + const chatClientWithUser = await createClient(); + const getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + + render( + , + ); + + await waitFor(() => expect(chatClientWithUser.offlineDb).toBeDefined()); + await waitFor(() => expect(getEncryptionKey).toHaveBeenCalled()); + }); + + it('does not re-initialize when getEncryptionKey is a new function every render', async () => { + const chatClientWithUser = await createClient(); + const resolveKey = jest.fn().mockResolvedValue('a-stable-key'); + + // An inline arrow is the shape integrators reach for first, so a changing + // identity must not restart initialization on every render. + const { rerender } = render( + resolveKey()} + />, + ); + + await waitFor(() => expect(chatClientWithUser.offlineDb).toBeDefined()); + const initSpy = track(jest.spyOn(chatClientWithUser.offlineDb!, 'init')); + + rerender( + resolveKey()} + />, + ); + rerender( + resolveKey()} + />, + ); + + await waitFor(() => expect(initSpy).not.toHaveBeenCalled()); + }); + + it.each<[string, () => Promise, string]>([ + ['the key cannot be read', () => Promise.resolve(undefined), 'ENCRYPTION_KEY_UNAVAILABLE'], + [ + 'the key getter throws', + () => Promise.reject(new Error('keychain is locked')), + 'ENCRYPTION_KEY_UNAVAILABLE', + ], + ])('throws %s so an error boundary can decide', async (_label, getKey, code) => { + const chatClientWithUser = await createClient(); + track(jest.spyOn(console, 'warn').mockImplementation(() => undefined)); + track(jest.spyOn(console, 'error').mockImplementation(() => undefined)); + track(jest.spyOn(console, 'log').mockImplementation(() => undefined)); + const onCatch = jest.fn(); + + const { getByTestId } = render( + + + + + , + ); + + await waitFor(() => expect(getByTestId('boundary')).toBeTruthy()); + expect(onCatch).toHaveBeenCalledWith(expect.any(SqliteClientError)); + expect((onCatch.mock.calls[0][0] as SqliteClientError).code).toBe(code); + // Never silently downgraded to online-only. + expect(() => getByTestId('children')).toThrow(); + }); + + it('throws when the native build has no SQLCipher', async () => { + const chatClientWithUser = await createClient(); + track(jest.spyOn(console, 'error').mockImplementation(() => undefined)); + track(jest.spyOn(console, 'log').mockImplementation(() => undefined)); + track(jest.spyOn(sqliteMock, 'isSQLCipher').mockReturnValue(false)); + const onCatch = jest.fn(); + + const { getByTestId } = render( + + Promise.resolve('a-stable-key')} + /> + , + ); + + await waitFor(() => expect(getByTestId('boundary')).toBeTruthy()); + expect((onCatch.mock.calls[0][0] as SqliteClientError).code).toBe('SQLCIPHER_BUILD_MISSING'); + }); + + it('throws OFFLINE_DB_UNREADABLE without deleting the database', async () => { + const chatClientWithUser = await createClient(); + track(jest.spyOn(console, 'warn').mockImplementation(() => undefined)); + track(jest.spyOn(console, 'error').mockImplementation(() => undefined)); + track(jest.spyOn(console, 'log').mockImplementation(() => undefined)); + // Preflight passes, then the first read of the file fails to decrypt. + track( + jest + .spyOn(SqliteClient, 'getUserPragmaVersion') + .mockRejectedValue(new Error('Querying for user_version failed: file is not a database')), + ); + const deleteSpy = track(jest.spyOn(SqliteClient, 'deleteDatabase')); + const onCatch = jest.fn(); + + const { getByTestId } = render( + + Promise.resolve('a-stable-key')} + /> + , + ); + + await waitFor(() => expect(getByTestId('boundary')).toBeTruthy()); + expect((onCatch.mock.calls[0][0] as SqliteClientError).code).toBe('OFFLINE_DB_UNREADABLE'); + // Wiping is the integrator's decision, made from the boundary. + expect(deleteSpy).not.toHaveBeenCalled(); + }); + + it('never attaches an offline DB it cannot open', async () => { + const chatClientWithUser = await createClient(); + track(jest.spyOn(console, 'warn').mockImplementation(() => undefined)); + track(jest.spyOn(console, 'error').mockImplementation(() => undefined)); + track(jest.spyOn(console, 'log').mockImplementation(() => undefined)); + const setOfflineDBApiSpy = track(jest.spyOn(chatClientWithUser, 'setOfflineDBApi')); + + const { getByTestId } = render( + undefined}> + Promise.resolve(undefined)} + /> + , + ); + + await waitFor(() => expect(getByTestId('boundary')).toBeTruthy()); + + // Parts of the client write through `client.offlineDb` without checking that it + // initialized - queryChannels upserts into it - so an instance we cannot open + // would turn those writes into rejections. + expect(setOfflineDBApiSpy).not.toHaveBeenCalled(); + expect(chatClientWithUser.offlineDb).toBeUndefined(); + }); + + it('renders normally when nothing is wrong with encryption', async () => { + const chatClientWithUser = await createClient(); + const onCatch = jest.fn(); + + const { getByTestId } = render( + + Promise.resolve('a-stable-key')} + > + + + , + ); + + await waitFor(() => expect(getByTestId('children')).toBeTruthy()); + expect(onCatch).not.toHaveBeenCalled(); + }); +}); diff --git a/package/src/index.ts b/package/src/index.ts index 6c8f8c1f45..cb8584481d 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -40,7 +40,7 @@ export { default as ruTranslations } from './i18n/ru.json'; export { default as trTranslations } from './i18n/tr.json'; export * from './state-store'; -export { SqliteClient } from './store/SqliteClient'; +export { SqliteClient, SqliteClientError, type SqliteClientErrorCode } from './store/SqliteClient'; export { OfflineDB } from './store/OfflineDB'; export { version } from './version.json'; diff --git a/package/src/mock-builders/DB/mock.ts b/package/src/mock-builders/DB/mock.ts index 7fa94cfc65..8d03370c98 100644 --- a/package/src/mock-builders/DB/mock.ts +++ b/package/src/mock-builders/DB/mock.ts @@ -1,3 +1,5 @@ +import { rmSync } from 'fs'; + import Sqlite3 from 'better-sqlite3'; import type { PreparedQueries } from '../../store/types'; @@ -6,6 +8,11 @@ let db: Sqlite3.Database; const testDbName = `foobar-${process.env.JEST_WORKER_ID ?? '0'}.db`; export const sqliteMock = { + // better-sqlite3 has no SQLCipher, so an `encryptionKey` passed to open() is + // simply ignored. Reporting a SQLCipher build keeps the encrypted path + // exercisable in tests; whether the bytes on disk are actually encrypted can + // only be verified on a device. Spy on this to test the build-missing guard. + isSQLCipher: () => true, open: () => { db = new Sqlite3(testDbName); db.pragma('journal_mode = MEMORY'); @@ -18,6 +25,12 @@ export const sqliteMock = { status: 0, }; }, + // Mirrors op-sqlite's delete(): closes the handle and unlinks the file, so a + // subsequent open() starts from an empty database. + delete: () => { + db.close(); + rmSync(testDbName, { force: true }); + }, execute: async (queryInput: string, params: unknown[]) => { const query = queryInput.trim().toLowerCase(); diff --git a/package/src/store/OfflineDB.ts b/package/src/store/OfflineDB.ts index d0d6a408fc..0d182427fb 100644 --- a/package/src/store/OfflineDB.ts +++ b/package/src/store/OfflineDB.ts @@ -9,20 +9,30 @@ import type { } from 'stream-chat'; import * as api from './apis'; -import { SqliteClient } from './SqliteClient'; +import { SqliteClient, SqliteClientError } from './SqliteClient'; export class OfflineDB extends AbstractOfflineDB { constructor({ client, + getEncryptionKey, maxSyncEventsLimit, }: { client: StreamChat; + /** + * Supplies the SQLCipher key the offline database is opened with. See + * {@link SqliteClient.getEncryptionKey} for the stability requirement. + */ + getEncryptionKey?: () => Promise; maxSyncEventsLimit?: number | false; }) { super({ client, syncMaxEventCount: maxSyncEventsLimit === false ? undefined : maxSyncEventsLimit, }); + // Assigned unconditionally: SqliteClient holds this statically, so leaving a + // previous instance's getter in place would keep encrypting after the caller + // stopped asking for it. + SqliteClient.getEncryptionKey = getEncryptionKey; } upsertCidsForQuery = api.upsertCidsForQuery; @@ -106,5 +116,25 @@ export class OfflineDB extends AbstractOfflineDB { executeSqlBatch = SqliteClient.executeSqlBatch; - initializeDB = SqliteClient.initializeDatabase; + /** + * Why the most recent {@link initializeDB} failed, if it did. + * + * `AbstractOfflineDB.init` catches whatever `initializeDB` throws and does not + * re-throw it, so a caller has no way to see the reason. Recording it here on the + * way out gives `` something to read back once `init` has settled. + */ + initializationError: SqliteClientError | undefined; + + initializeDB = async () => { + this.initializationError = undefined; + try { + return await SqliteClient.initializeDatabase(); + } catch (error) { + if (error instanceof SqliteClientError) { + this.initializationError = error; + } + // Re-thrown so `AbstractOfflineDB.init` still marks the database uninitialized. + throw error; + } + }; } diff --git a/package/src/store/SqliteClient.ts b/package/src/store/SqliteClient.ts index 733fc95dd8..ddca086034 100644 --- a/package/src/store/SqliteClient.ts +++ b/package/src/store/SqliteClient.ts @@ -23,6 +23,29 @@ import { tables } from './schema'; import { createCreateTableQuery } from './sqlite-utils/createCreateTableQuery'; import type { PreparedBatchQueries, PreparedQueries, Scalar, Table } from './types'; +/** + * Why the offline database could not be opened. The first two only arise when + * {@link SqliteClient.getEncryptionKey} is set; `OFFLINE_DB_UNREADABLE` can also mean + * plain corruption, or a database left behind from the other encryption mode. + */ +export type SqliteClientErrorCode = + | 'SQLCIPHER_BUILD_MISSING' + | 'ENCRYPTION_KEY_UNAVAILABLE' + | 'OFFLINE_DB_UNREADABLE'; + +export class SqliteClientError extends Error { + public readonly code: SqliteClientErrorCode; + + constructor(code: SqliteClientErrorCode, message: string, options?: { cause?: unknown }) { + super(message); + this.name = 'SqliteClientError'; + this.code = code; + // Assigned here rather than passed through `super(message, { cause })` because + // Hermes does not reliably honour the ErrorOptions overload. + this.cause = options?.cause; + } +} + /** * SqliteClient takes care of any direct interaction with sqlite. * This way usage @op-engineering/op-sqlite package is scoped to a single class/file. @@ -35,10 +58,123 @@ export class SqliteClient { static logger: Logger | undefined; static db: _InternalDB | undefined; + /** + * Supplies the SQLCipher key the offline database is opened with; `undefined` + * opens it unencrypted, which is the default. The key must be stable for the + * lifetime of the database file - there is no rekey path, so a database it cannot + * decrypt is wiped and rebuilt from the server. + */ + static getEncryptionKey: (() => Promise) | undefined; + + /** Key resolved by {@link preflightEncryption}, consumed by the next {@link openDB}. */ + private static preflightedKey: string | undefined; + + /** Busy/disk/memory failures. Checked first: wiping over these destroys a good db. */ + private static TRANSIENT_ERROR = + /database is locked|SQLITE_BUSY|SQLITE_LOCKED|disk i\/o|SQLITE_IOERR|unable to open|SQLITE_CANTOPEN|out of memory|readonly/i; + + /** + * The bytes on disk cannot be read with the key we have: wrong/rotated key, + * plaintext-encrypted mismatch or corruption. SQLCipher has no decrypt specific + * code and overloads NOTADB (26), occasionally CORRUPT (11). + */ + private static UNREADABLE_ERROR = + /not a database|file is encrypted|malformed|disk image is malformed|SQLite (?:error )?code:?\s*(?:26|11)\b|NOTADB|SQLITE_CORRUPT/i; + static getDbVersion = () => SqliteClient.dbVersion; // Force a specific db version. This is mainly useful for testsuit. static setDbVersion = (version: number) => (SqliteClient.dbVersion = version); + /** + * Records and re-throws. Deliberately does not write to the console: the error is + * thrown, so logging it here would duplicate whatever the caller's error boundary + * reports - and in dev React already logs every boundary-caught error, which is what + * LogBox turns red. + */ + private static recordError = (e: SqliteClientError) => { + this.logger?.('error', e.message, { tag: e.code }); + + throw e; + }; + + /** + * Resolves the encryption key without opening the database, so callers can decide + * whether to attach an `OfflineDB` at all. Parts of the client write through + * `client.offlineDb` without checking that it initialized (`queryChannels` upserts + * into it), so attaching one we cannot open turns those writes into rejections. + * + * Throws {@link SqliteClientError}. The key is handed to the next + * {@link openDB} rather than read from `getEncryptionKey` twice. + */ + static preflightEncryption = async () => { + try { + this.preflightedKey = await this.resolveEncryptionKey(); + } catch (e) { + if (e instanceof SqliteClientError) { + this.recordError(e); + } + throw e; + } + }; + + /** + * The key to open with, or `undefined` when the database is meant to be + * unencrypted. Throws rather than silently falling back to an unencrypted + * database, which would hand an integration that asked for encryption a plaintext + * cache of its users' messages. + */ + private static resolveEncryptionKey = async () => { + const { getEncryptionKey } = this; + + if (!getEncryptionKey) { + return undefined; + } + + // A non-SQLCipher build accepts `encryptionKey` at the JSI boundary and then + // drops it - plaintext database, no error anywhere. `isSQLCipher` has existed + // since op-sqlite 9, well below the peer floor, so the typeof check is not really + // necessary but we'll keep it in case something changes in the future so that + // we at least have a clearer error. + if (sqlite === undefined) { + throw new SqliteClientError( + 'SQLCIPHER_BUILD_MISSING', + 'getEncryptionKey was provided but "@op-engineering/op-sqlite" is not installed.', + ); + } + if (typeof sqlite.isSQLCipher !== 'function' || !sqlite.isSQLCipher()) { + throw new SqliteClientError( + 'SQLCIPHER_BUILD_MISSING', + 'getEncryptionKey was provided but @op-engineering/op-sqlite was not built with ' + + 'SQLCipher, so the key would be silently ignored and the offline database ' + + 'written in plaintext. Add { "op-sqlite": { "sqlcipher": true } } to your ' + + "application's package.json and rebuild, or remove getEncryptionKey.", + ); + } + + let encryptionKey: string | undefined; + + try { + encryptionKey = await getEncryptionKey(); + } catch (error) { + throw new SqliteClientError( + 'ENCRYPTION_KEY_UNAVAILABLE', + 'getEncryptionKey threw, so the offline database cannot be opened.', + { cause: error }, + ); + } + + // Not being handed a key is not the same as being handed the wrong one, so a locked + // keychain must not cost us a database we can still read later. + if (!encryptionKey) { + throw new SqliteClientError( + 'ENCRYPTION_KEY_UNAVAILABLE', + 'getEncryptionKey resolved without a key, so the offline database cannot be opened.', + ); + } + + return encryptionKey; + }; + static openDB = async () => { try { if (sqlite === undefined) { @@ -46,13 +182,24 @@ export class SqliteClient { 'Please install "@op-engineering/op-sqlite" package to enable offline support', ); } + const encryptionKey = this.preflightedKey ?? (await this.resolveEncryptionKey()); + this.preflightedKey = undefined; + this.db = sqlite.open({ location: SqliteClient.dbLocation, name: SqliteClient.dbName, + ...(encryptionKey ? { encryptionKey } : {}), }); + // Note: this will not fail on an encryption key mismatch, as we do not read + // any pages, but rather look at a connection level flag. The first failure + // is going to be whatever actually reads something, which is going to be + // the user_version read in initializeDatabase. await this.db?.execute('PRAGMA foreign_keys = ON', []); } catch (e) { + if (e instanceof SqliteClientError) { + throw e; + } this.logger?.('error', `Error opening database ${SqliteClient.dbName}`, { error: e, }); @@ -154,7 +301,23 @@ export class SqliteClient { return true; }; - static initializeDatabase = async () => { + /** + * Whether the file cannot be read with the key we have, as opposed to being + * temporarily unavailable (busy, locked, disk). Works off message text because + * op-sqlite rejects with a plain Error and this class re-wraps those messages, so + * no numeric code survives. Drives `OFFLINE_DB_UNREADABLE`. + */ + static isUnreadableDbError = (e: unknown) => { + const message = String((e as Error)?.message ?? e); + + if (this.TRANSIENT_ERROR.test(message)) { + return false; + } + + return this.UNREADABLE_ERROR.test(message); + }; + + static initializeDatabase = async (): Promise => { try { await SqliteClient.openDB(); const version = await SqliteClient.getUserPragmaVersion(); @@ -180,6 +343,24 @@ export class SqliteClient { return true; } catch (e) { + if (e instanceof SqliteClientError) { + this.recordError(e); + } + + if (this.isUnreadableDbError(e)) { + this.recordError( + new SqliteClientError( + 'OFFLINE_DB_UNREADABLE', + 'The offline database exists but could not be read. Usually the encryption ' + + 'key changed, or encryption was turned on or off while a database from ' + + 'the other mode was still on disk. Delete it with ' + + 'SqliteClient.deleteDatabase() and re-mount to rebuild from the server - ' + + 'everything in it is a cache, except queued offline actions, which are lost.', + { cause: e }, + ), + ); + } + console.log('Error initializing DB', e); this.logger?.('error', 'Error initializing DB', { dbLocation: SqliteClient.dbLocation, diff --git a/package/src/store/__tests__/SqliteClient.test.ts b/package/src/store/__tests__/SqliteClient.test.ts new file mode 100644 index 0000000000..0ee0f2e406 --- /dev/null +++ b/package/src/store/__tests__/SqliteClient.test.ts @@ -0,0 +1,258 @@ +import { sqliteMock } from '../../mock-builders/DB/mock'; +import { SqliteClient, SqliteClientError } from '../SqliteClient'; + +// Captured before any spy is installed so the spy can call through to a real +// better-sqlite3 handle while still observing the arguments open() was given. +const openDatabase = sqliteMock.open; + +/** Runs `initializeDatabase` once and returns the error it threw. */ +const captureInitError = async () => { + try { + await SqliteClient.initializeDatabase(); + } catch (error) { + return error as SqliteClientError; + } + throw new Error('expected initializeDatabase to reject, but it resolved'); +}; + +describe('SqliteClient encryption', () => { + let openSpy: jest.SpyInstance>; + let deleteMocks: jest.Mock[]; + + beforeEach(() => { + SqliteClient.getEncryptionKey = undefined; + SqliteClient.db = undefined; + SqliteClient.logger = jest.fn(); + + deleteMocks = []; + openSpy = jest.spyOn(sqliteMock, 'open').mockImplementation(() => { + const db = openDatabase(); + const originalDelete = db.delete; + const deleteMock = jest.fn(() => originalDelete()); + deleteMocks.push(deleteMock); + return { ...db, delete: deleteMock }; + }); + + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + jest.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + SqliteClient.getEncryptionKey = undefined; + SqliteClient.logger = undefined; + SqliteClient.db = undefined; + }); + + describe('opening without encryption', () => { + it('does not pass an encryption key when no getter is configured', async () => { + await expect(SqliteClient.initializeDatabase()).resolves.toBe(true); + + expect(openSpy).toHaveBeenCalledTimes(1); + expect(openSpy.mock.calls[0][0]).not.toHaveProperty('encryptionKey'); + }); + + it('never consults isSQLCipher when no getter is configured', async () => { + const isSQLCipherSpy = jest.spyOn(sqliteMock, 'isSQLCipher'); + + await SqliteClient.initializeDatabase(); + + expect(isSQLCipherSpy).not.toHaveBeenCalled(); + }); + }); + + describe('opening with encryption', () => { + it('passes the resolved key to open()', async () => { + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + + await expect(SqliteClient.initializeDatabase()).resolves.toBe(true); + + expect(SqliteClient.getEncryptionKey).toHaveBeenCalledTimes(1); + expect(openSpy.mock.calls[0][0]).toMatchObject({ encryptionKey: 'a-stable-key' }); + }); + + it('refuses to open at all when op-sqlite has no SQLCipher build', async () => { + jest.spyOn(sqliteMock, 'isSQLCipher').mockReturnValue(false); + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + + expect(error.code).toBe('SQLCIPHER_BUILD_MISSING'); + // The whole point: no database is created, so nothing is written in plaintext. + expect(openSpy).not.toHaveBeenCalled(); + // Nor is the key ever requested - the build is unusable regardless of it. + expect(SqliteClient.getEncryptionKey).not.toHaveBeenCalled(); + expect(deleteMocks).toHaveLength(0); + }); + + it('refuses to open when isSQLCipher is missing from the installed op-sqlite', async () => { + // An op-sqlite too old to expose the check cannot be verified, so it is + // treated exactly like a build without SQLCipher. + const { isSQLCipher } = sqliteMock; + // @ts-expect-error deliberately simulating an older op-sqlite + delete sqliteMock.isSQLCipher; + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + + try { + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + expect(error.code).toBe('SQLCIPHER_BUILD_MISSING'); + expect(openSpy).not.toHaveBeenCalled(); + } finally { + sqliteMock.isSQLCipher = isSQLCipher; + } + }); + }); + + describe('when the encryption key cannot be obtained', () => { + it('gives up without wiping when the getter throws', async () => { + const cause = new Error('keychain is locked'); + SqliteClient.getEncryptionKey = jest.fn().mockRejectedValue(cause); + + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + + expect(error.code).toBe('ENCRYPTION_KEY_UNAVAILABLE'); + expect(error.cause).toBe(cause); + expect(openSpy).not.toHaveBeenCalled(); + // Not being handed a key says nothing about the database on disk, so it stays. + expect(deleteMocks).toHaveLength(0); + }); + + it('gives up without wiping when the getter resolves without a key', async () => { + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue(undefined); + + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + + expect(error.code).toBe('ENCRYPTION_KEY_UNAVAILABLE'); + expect(openSpy).not.toHaveBeenCalled(); + expect(deleteMocks).toHaveLength(0); + }); + + it('treats an empty string as no key', async () => { + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue(''); + + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + + expect(error.code).toBe('ENCRYPTION_KEY_UNAVAILABLE'); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('clears a recorded encryption error once initialization succeeds', async () => { + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue(undefined); + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + expect(error).toBeDefined(); + + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + await expect(SqliteClient.initializeDatabase()).resolves.toBe(true); + }); + }); + + describe('when the database cannot be read', () => { + const notADatabase = () => + new Error('Querying for user_version failed: Error: file is not a database'); + + it('throws OFFLINE_DB_UNREADABLE instead of wiping it', async () => { + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + jest.spyOn(SqliteClient, 'getUserPragmaVersion').mockRejectedValue(notADatabase()); + + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + + expect(error.code).toBe('OFFLINE_DB_UNREADABLE'); + // Deleting it is the caller's decision - the pending-task queue lives in there. + expect(deleteMocks.every((m) => m.mock.calls.length === 0)).toBe(true); + }); + + it('keeps the original cause on the thrown error', async () => { + const cause = notADatabase(); + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + jest.spyOn(SqliteClient, 'getUserPragmaVersion').mockRejectedValue(cause); + + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + + expect(error.cause).toBe(cause); + }); + + it('throws even with no encryption configured', async () => { + // Turning encryption off leaves an encrypted file and no key to read it. Simply + // reporting failure would leave offline support uninitialized forever, so the + // caller is told and can delete it. + jest.spyOn(SqliteClient, 'getUserPragmaVersion').mockRejectedValue(notADatabase()); + + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + + expect(error.code).toBe('OFFLINE_DB_UNREADABLE'); + }); + + it('does not throw on a transient failure', async () => { + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + jest + .spyOn(SqliteClient, 'getUserPragmaVersion') + .mockRejectedValue(new Error('Query failed: Error: database is locked')); + + await expect(SqliteClient.initializeDatabase()).resolves.toBe(false); + }); + }); + + describe('isUnreadableDbError', () => { + it.each([ + 'file is not a database', + 'SQLite error code: 26', + 'SQLite code:11', + 'NOTADB', + 'SQLITE_CORRUPT', + 'database disk image is malformed', + 'file is encrypted or is not a database', + ])('treats %p as unreadable', (message) => { + expect(SqliteClient.isUnreadableDbError(new Error(message))).toBe(true); + }); + + it.each([ + 'database is locked', + 'SQLITE_BUSY', + 'SQLITE_LOCKED', + 'disk I/O error', + 'SQLITE_IOERR', + 'unable to open database file', + 'SQLITE_CANTOPEN', + 'out of memory', + 'attempt to write a readonly database', + 'DB is not open or initialized.', + 'Please install "@op-engineering/op-sqlite" package to enable offline support', + ])('does not treat %p as unreadable', (message) => { + expect(SqliteClient.isUnreadableDbError(new Error(message))).toBe(false); + }); + + it('lets a transient reason win when both are present in one message', () => { + // Pins the precedence rule: a message that could be read either way must not + // trigger a wipe. Guessing wrong in this direction destroys a good database. + expect( + SqliteClient.isUnreadableDbError( + new Error('unable to open database file: file is not a database'), + ), + ).toBe(false); + }); + + it('handles non-Error throwables', () => { + expect(SqliteClient.isUnreadableDbError('file is not a database')).toBe(true); + expect(SqliteClient.isUnreadableDbError(undefined)).toBe(false); + }); + }); +}); From 87279244c90a8719fda17f6bdaf8dec6d4e384c8 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 19 Aug 2026 23:53:23 +0200 Subject: [PATCH 2/6] feat: introduce db init hook --- package/src/components/Chat/Chat.tsx | 80 ++---------- .../Chat/hooks/useInitializeOfflineDb.ts | 117 ++++++++++++++++++ package/src/store/OfflineDB.ts | 3 +- 3 files changed, 127 insertions(+), 73 deletions(-) create mode 100644 package/src/components/Chat/hooks/useInitializeOfflineDb.ts diff --git a/package/src/components/Chat/Chat.tsx b/package/src/components/Chat/Chat.tsx index 3614347381..32a765d32e 100644 --- a/package/src/components/Chat/Chat.tsx +++ b/package/src/components/Chat/Chat.tsx @@ -1,4 +1,4 @@ -import React, { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { PropsWithChildren, useEffect, useMemo, useState } from 'react'; import { Platform } from 'react-native'; import { Channel, OfflineDBState } from 'stream-chat'; @@ -6,6 +6,7 @@ import { Channel, OfflineDBState } from 'stream-chat'; import { useClientMutedUsers } from './hooks'; import { useAppSettings } from './hooks/useAppSettings'; import { useCreateChatContext } from './hooks/useCreateChatContext'; +import { useInitializeOfflineDb } from './hooks/useInitializeOfflineDb'; import { useIsOnline } from './hooks/useIsOnline'; import { ChannelsStateProvider } from '../../contexts/channelsStateContext/ChannelsStateContext'; @@ -24,8 +25,6 @@ import init from '../../init'; import { NativeHandlers } from '../../native'; import { DEFAULT_MAX_SYNC_EVENTS_LIMIT } from '../../store/constants'; -import { OfflineDB } from '../../store/OfflineDB'; -import { SqliteClient, SqliteClientError } from '../../store/SqliteClient'; import type { Streami18n } from '../../utils/i18n/Streami18n'; import { installNativeMultipartAdapter } from '../../utils/installNativeMultipartAdapter'; @@ -237,15 +236,6 @@ const ChatWithContext = (props: PropsWithChildren) => { const { ChatLoadingIndicator } = useComponentsContext(); const [channel, setChannel] = useState(); - /** - * Why this mount could not open the offline database, or `undefined` if it did. - * - * Captured per attempt rather than observed from a longer-lived source: a value that - * outlives the attempt would be re-read during the first render after a re-mount and - * throw before that mount's own attempt could run, so an error boundary that - * re-mounts to retry would loop forever. - */ - const [initializationError, setInitializationError] = useState(); // Setup translators const translators = useStreami18n(i18nInstance); @@ -306,57 +296,12 @@ const ChatWithContext = (props: PropsWithChildren) => { const setActiveChannel = (newChannel?: Channel) => setChannel(newChannel); - const getEncryptionKeyRef = useRef(getEncryptionKey); - getEncryptionKeyRef.current = getEncryptionKey; - const isEncryptionEnabled = !!getEncryptionKey; - - const initializeDatabase = useCallback(async () => { - if (!(userID && enableOfflineSupport)) { - return; - } - - if (!client.offlineDb) { - const getKey = isEncryptionEnabled - ? () => getEncryptionKeyRef.current?.() ?? Promise.resolve(undefined) - : undefined; - - // Confirm the database can be opened before attaching it: the client writes - // through `client.offlineDb` without checking that it initialized, so one we - // cannot open turns those writes into rejections (the channel list never - // loads). With nothing attached they take their online path. - if (getKey) { - SqliteClient.getEncryptionKey = getKey; - try { - await SqliteClient.preflightEncryption(); - } catch (error) { - if (error instanceof SqliteClientError) { - // Surfaced by re-throwing during render; see below. - setInitializationError(error); - return; - } - throw error; - } - } - - client.setOfflineDBApi( - new OfflineDB({ client, getEncryptionKey: getKey, maxSyncEventsLimit }), - ); - } - - const { offlineDb } = client; - if (offlineDb) { - await offlineDb.init(userID); - // `init` never re-throws, so the reason is read back off the instance, which - // recorded it on the way out. - setInitializationError( - offlineDb instanceof OfflineDB ? offlineDb.initializationError : undefined, - ); - } - }, [userID, enableOfflineSupport, client, maxSyncEventsLimit, isEncryptionEnabled]); - - useEffect(() => { - initializeDatabase(); - }, [initializeDatabase]); + useInitializeOfflineDb({ + client, + enabled: enableOfflineSupport, + options: { getEncryptionKey, maxSyncEventsLimit }, + userID, + }); useEffect(() => { if (!client) { @@ -400,15 +345,6 @@ const ChatWithContext = (props: PropsWithChildren) => { setActiveChannel, }); - // Encryption is a security posture, not something to quietly downgrade. Rather than - // continuing without the encrypted cache the caller asked for, the failure is raised - // here so an error boundary above `` can decide what to do - re-mount with - // `enableOfflineSupport={false}`, wipe the database and retry, or surface it to the - // user. Thrown from render because a boundary cannot catch an async rejection. - if (initializationError) { - throw initializationError; - } - if (userID && enableOfflineSupport && !initialisedDatabase) { // if user id has been set and offline support is enabled, we need to wait for database to be initialised return ChatLoadingIndicator ? : null; diff --git a/package/src/components/Chat/hooks/useInitializeOfflineDb.ts b/package/src/components/Chat/hooks/useInitializeOfflineDb.ts new file mode 100644 index 0000000000..47fcf28ab9 --- /dev/null +++ b/package/src/components/Chat/hooks/useInitializeOfflineDb.ts @@ -0,0 +1,117 @@ +import { useCallback, useEffect, useState } from 'react'; + +import type { StreamChat } from 'stream-chat'; + +import { useStableCallback } from '../../../hooks/useStableCallback'; +import { OfflineDB } from '../../../store/OfflineDB'; +import { SqliteClient, SqliteClientError } from '../../../store/SqliteClient'; + +export type InitializeOfflineDbOptions = { + /** + * Encrypts the offline database at rest with SQLCipher, using the key this resolves + * to. Leaving it unset opens the database unencrypted, which is the default. See + * `ChatProps.getEncryptionKey` for the build flag it requires, the stability + * requirement, and how failures are surfaced. + */ + getEncryptionKey?: () => Promise; + /** + * Optional positive cap on the number of events a single `/sync` response may + * contain before the offline sync manager skips replaying those events into local + * storage. `false` opts out entirely. + */ + maxSyncEventsLimit?: number | false; +}; + +export type UseInitializeOfflineDbParams = { + client: StreamChat; + /** Whether offline support is enabled at all. */ + enabled: boolean; + options?: InitializeOfflineDbOptions; + userID?: string; +}; + +/** + * Attaches an offline database to the client and initializes it for a user. + * + * **Raises** whatever prevented the database from opening, from render, so an error + * boundary above the caller can decide what to do. The offline database is never + * silently downgraded, because an integration that asked for encryption must not end + * up with an unencrypted cache. + */ +export const useInitializeOfflineDb = ({ + client, + enabled, + options, + userID, +}: UseInitializeOfflineDbParams) => { + /** + * Why this attempt could not open the offline database. + * + * Held per attempt rather than read from a longer-lived source: a value that outlived + * the attempt would be seen during the first render after a re-mount and raised + * before that mount's own attempt could run, so an error boundary that re-mounts to + * retry would loop forever. + */ + const [initializationError, setInitializationError] = useState(); + + const { getEncryptionKey, maxSyncEventsLimit } = options ?? {}; + + // `getEncryptionKey` is overwhelmingly likely to be an inline arrow. Stabilising it + // keeps a new identity per render out of the dependencies below, while still calling + // whatever the latest prop is. + const resolveEncryptionKey = useStableCallback( + () => getEncryptionKey?.() ?? Promise.resolve(undefined), + ); + const isEncryptionEnabled = !!getEncryptionKey; + + const initialize = useCallback(async () => { + if (!(userID && enabled)) { + return; + } + + if (!client.offlineDb) { + const keyGetter = isEncryptionEnabled ? resolveEncryptionKey : undefined; + + // Confirm the database can be opened before attaching it: the client writes + // through `client.offlineDb` without checking that it initialized, so one we + // cannot open turns those writes into rejections (and UI is affected directly). + if (keyGetter) { + SqliteClient.getEncryptionKey = keyGetter; + try { + await SqliteClient.preflightEncryption(); + } catch (error) { + if (error instanceof SqliteClientError) { + setInitializationError(error); + return; + } + throw error; + } + } + + client.setOfflineDBApi( + new OfflineDB({ client, getEncryptionKey: keyGetter, maxSyncEventsLimit }), + ); + } + + const { offlineDb } = client; + if (offlineDb) { + await offlineDb.init(userID); + // Note: Since `init()` currently swallows errors by design, we have to rely + // on consuming the error later in order to be able to still rethrow without + // introducing a breaking change. + // TODO: The DB API should be changed in the next major to always throw upwards + // and let integrators handle it if necessary. + setInitializationError( + offlineDb instanceof OfflineDB ? offlineDb.initializationError : undefined, + ); + } + }, [client, enabled, isEncryptionEnabled, maxSyncEventsLimit, resolveEncryptionKey, userID]); + + useEffect(() => { + initialize(); + }, [initialize]); + + if (initializationError) { + throw initializationError; + } +}; diff --git a/package/src/store/OfflineDB.ts b/package/src/store/OfflineDB.ts index 0d182427fb..09b565a75e 100644 --- a/package/src/store/OfflineDB.ts +++ b/package/src/store/OfflineDB.ts @@ -121,7 +121,8 @@ export class OfflineDB extends AbstractOfflineDB { * * `AbstractOfflineDB.init` catches whatever `initializeDB` throws and does not * re-throw it, so a caller has no way to see the reason. Recording it here on the - * way out gives `` something to read back once `init` has settled. + * way out gives the caller something to read back once `init` has settled. Kept on + * the instance rather than a static so two clients cannot overwrite each other. */ initializationError: SqliteClientError | undefined; From 2fae15b8b16b6cd9c7fd3fbe675be518a77d135a Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 20 Aug 2026 00:38:30 +0200 Subject: [PATCH 3/6] fix: remove redundant key --- examples/SampleApp/App.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index efb6384414..ae2c80ccb4 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -434,7 +434,6 @@ const DrawerNavigatorWrapper: React.FC<{ getEncryptionKey={getEncryptionKey} i18nInstance={i18nInstance} isMessageAIGenerated={isMessageAIGenerated} - key={attempt} useNativeMultipartUpload > From 22a4a14de5ccad2009a2e12a62b61121df055567 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 20 Aug 2026 02:13:32 +0200 Subject: [PATCH 4/6] chore: remove cipher --- examples/SampleApp/App.tsx | 51 +++++++++++------------------ examples/SampleApp/ios/Podfile.lock | 6 +--- examples/SampleApp/package.json | 3 -- 3 files changed, 20 insertions(+), 40 deletions(-) diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index ae2c80ccb4..348423075b 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -332,38 +332,22 @@ const DrawerNavigator: React.FC = () => ( const isMessageAIGenerated = (message: LocalMessage) => !!message.ai_generated; -/** - * Demonstrates encryption-at-rest for the offline database. Paired with - * `{ "op-sqlite": { "sqlcipher": true } }` in this app's package.json, which is what - * actually builds op-sqlite against SQLCipher - without it the SDK refuses to open - * the database rather than write it in plaintext. - * - * A hardcoded constant is fine for a sample and wrong for a real app: it ships the - * key inside the binary, so anyone who can read the database can also read the key. - * A real integration reads the key from the iOS Keychain / Android Keystore, and - * rotates a key-encryption key around a stable database key (envelope encryption) so - * rotation does not force the cache to be wiped. - * - * What this does illustrate is the invariant that matters: the same key comes back on - * every launch. A key that changes wipes the offline cache and rebuilds it from the - * server. - */ -const getEncryptionKey = () => Promise.resolve('sample-app-offline-db-key'); - /** * `` throws a {@link SqliteClientError} from render when it cannot open the - * offline database with the encryption that was asked for. It never downgrades to - * running unencrypted on its own - recovery is the application's decision. + * offline database - most often `OFFLINE_DB_UNREADABLE`, meaning the file on disk + * cannot be read (corruption, or a database left behind from a different encryption + * mode). It never silently continues without the cache; recovery is the application's + * decision. * - * This boundary shows the two sensible responses: + * The recommended recovery, shown here: the contents are a cache, so delete the + * database and let it rebuild from the server. The only real loss is actions that were + * queued while offline, so a real app may want to confirm with the user first. * - * - `OFFLINE_DB_UNREADABLE` - the file exists but this key cannot read it (the key - * changed, or it predates encryption). The data is a cache, so delete it and retry; - * the only real loss is actions that were queued while offline. A real app may want - * to confirm with the user first. - * - `SQLCIPHER_BUILD_MISSING` / `ENCRYPTION_KEY_UNAVAILABLE` - there is no usable key, - * so a new database would be written in plaintext. Run online-only instead: no local - * cache means nothing unencrypted on disk. + * The `onGiveUp` path covers the codes that mean "no usable encryption key" + * (`SQLCIPHER_BUILD_MISSING`, `ENCRYPTION_KEY_UNAVAILABLE`). Those only occur when + * `` is given a `getEncryptionKey` prop, which this sample does not do - a new + * database would then be written in plaintext, so running online-only is the safe + * response. */ type BoundaryProps = React.PropsWithChildren<{ onGiveUp: () => void; @@ -372,7 +356,7 @@ type BoundaryProps = React.PropsWithChildren<{ type BoundaryState = { code?: SqliteClientErrorCode }; -class OfflineEncryptionBoundary extends React.Component { +class OfflineDbBoundary extends React.Component { state: BoundaryState = {}; // Must return state, and render() must stop rendering the failing subtree. Returning @@ -422,8 +406,12 @@ const DrawerNavigatorWrapper: React.FC<{ const [attempt, setAttempt] = useState(0); const [offlineSupport, setOfflineSupport] = useState(true); + // The boundary stops rendering its children once it has caught (see its render), and + // nothing else clears that. Keying it on both recovery levers re-mounts it when one is + // pulled - without that it would sit on a blank screen forever, having already deleted + // the database. return ( - setOfflineSupport(false)} onRetry={() => setAttempt((value) => value + 1)} @@ -431,7 +419,6 @@ const DrawerNavigatorWrapper: React.FC<{ - + ); }; diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 3122bf4153..e86287ae53 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -233,7 +233,6 @@ PODS: - Yoga - op-sqlite (17.1.2): - hermes-engine - - OpenSSL-Universal - RCTRequired - RCTTypeSafety - React-Core @@ -254,7 +253,6 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - OpenSSL-Universal (3.6.2000) - PromisesObjC (2.4.1) - PromisesSwift (2.4.1): - PromisesObjC (= 2.4.1) @@ -3059,7 +3057,6 @@ SPEC REPOS: - libdav1d - libwebp - nanopb - - OpenSSL-Universal - PromisesObjC - PromisesSwift - SDWebImage @@ -3301,8 +3298,7 @@ SPEC CHECKSUMS: nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 NitroModules: e0ac5f9a04e23cb2f378b51810ebc07ed63aeae9 NitroSound: a18e2d59d0d60c291586e622ce4752c53da73086 - op-sqlite: 6cf4cf717567180707bf372c894b27f9bce04a51 - OpenSSL-Universal: ecee7b138fa75a74ecf00d7ffd248fb584739b9e + op-sqlite: d8d5eae2bddb0b55d6f48cf7ac356b63d26cb4f0 PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273 PromisesSwift: 217dea0fd5d2ad65222a109c48698add13cc1c5b RCTDeprecation: bccb6545c26db881ecddfd83a3f9ea82aba1605f diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index ec8a0316fc..1fcd0fa999 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -2,9 +2,6 @@ "name": "sampleapp", "version": "4.14.7", "private": true, - "op-sqlite": { - "sqlcipher": true - }, "repository": { "type": "git", "url": "https://github.com/GetStream/stream-chat-react-native.git" From 64bcd9e8ec864b2c5c5183ec1914dcd5c9e55e4e Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 20 Aug 2026 02:50:22 +0200 Subject: [PATCH 5/6] chore: move err boundary to separate file --- examples/SampleApp/App.tsx | 68 +----------------- .../src/components/OfflineDbBoundary.tsx | 72 +++++++++++++++++++ 2 files changed, 73 insertions(+), 67 deletions(-) create mode 100644 examples/SampleApp/src/components/OfflineDbBoundary.tsx diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index 348423075b..14dc99a866 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -20,8 +20,6 @@ import { OverlayProvider, setupCommandUIMiddlewares, SqliteClient, - SqliteClientError, - type SqliteClientErrorCode, Streami18n, ThemeProvider, useOverlayContext, @@ -29,6 +27,7 @@ import { } from 'stream-chat-react-native'; import { MenuDrawer } from './src/components/MenuDrawer'; +import { OfflineDbBoundary } from './src/components/OfflineDbBoundary'; import { useSampleAppComponentOverrides } from './src/components/SampleAppComponentOverrides'; import { MessageInputFloatingConfigItem, @@ -332,71 +331,6 @@ const DrawerNavigator: React.FC = () => ( const isMessageAIGenerated = (message: LocalMessage) => !!message.ai_generated; -/** - * `` throws a {@link SqliteClientError} from render when it cannot open the - * offline database - most often `OFFLINE_DB_UNREADABLE`, meaning the file on disk - * cannot be read (corruption, or a database left behind from a different encryption - * mode). It never silently continues without the cache; recovery is the application's - * decision. - * - * The recommended recovery, shown here: the contents are a cache, so delete the - * database and let it rebuild from the server. The only real loss is actions that were - * queued while offline, so a real app may want to confirm with the user first. - * - * The `onGiveUp` path covers the codes that mean "no usable encryption key" - * (`SQLCIPHER_BUILD_MISSING`, `ENCRYPTION_KEY_UNAVAILABLE`). Those only occur when - * `` is given a `getEncryptionKey` prop, which this sample does not do - a new - * database would then be written in plaintext, so running online-only is the safe - * response. - */ -type BoundaryProps = React.PropsWithChildren<{ - onGiveUp: () => void; - onRetry: () => void; -}>; - -type BoundaryState = { code?: SqliteClientErrorCode }; - -class OfflineDbBoundary extends React.Component { - state: BoundaryState = {}; - - // Must return state, and render() must stop rendering the failing subtree. Returning - // null here would re-render the same children, they would throw again, and React - // would give up and unmount the whole app. - static getDerivedStateFromError(error: unknown) { - const code = (error as SqliteClientError | undefined)?.code; - if (!code) { - throw error; - } - return { code }; - } - - componentDidCatch(error: unknown) { - // Discriminated on `code` rather than `instanceof`: a string comparison cannot be - // defeated by two copies of the class ending up in one bundle. - const code = (error as SqliteClientError | undefined)?.code; - - if (code === 'OFFLINE_DB_UNREADABLE') { - // The recommended recovery: the contents are a cache, so drop the database and - // let it rebuild. Only actions queued while offline are lost. - try { - SqliteClient.deleteDatabase(); - } catch (deleteError) { - console.warn('[SampleApp] could not delete the offline database', deleteError); - } - this.props.onRetry(); - return; - } - - // No usable key, so a new database would be plaintext. Run online-only instead. - console.warn(`[SampleApp] offline encryption unavailable (${code}); going online-only`); - this.props.onGiveUp(); - } - - render() { - return this.state.code ? null : this.props.children; - } -} - const DrawerNavigatorWrapper: React.FC<{ chatClient: StreamChat; i18nInstance: Streami18n; diff --git a/examples/SampleApp/src/components/OfflineDbBoundary.tsx b/examples/SampleApp/src/components/OfflineDbBoundary.tsx new file mode 100644 index 0000000000..c332e53b39 --- /dev/null +++ b/examples/SampleApp/src/components/OfflineDbBoundary.tsx @@ -0,0 +1,72 @@ +import React from 'react'; + +import { + SqliteClient, + SqliteClientError, + type SqliteClientErrorCode, +} from 'stream-chat-react-native'; + +/** + * `` throws a {@link SqliteClientError} from render when it cannot open the + * offline database - most often `OFFLINE_DB_UNREADABLE`, meaning the file on disk + * cannot be read (corruption, or a database left behind from a different encryption + * mode). It never silently continues without the cache; recovery is the application's + * decision. + * + * The recommended recovery, shown here: the contents are a cache, so delete the + * database and let it rebuild from the server. The only real loss is actions that were + * queued while offline, so a real app may want to confirm with the user first. + * + * The `onGiveUp` path covers the codes that mean "no usable encryption key" + * (`SQLCIPHER_BUILD_MISSING`, `ENCRYPTION_KEY_UNAVAILABLE`). Those only occur when + * `` is given a `getEncryptionKey` prop, which this sample does not do - a new + * database would then be written in plaintext, so running online-only is the safe + * response. + */ +type BoundaryProps = React.PropsWithChildren<{ + onGiveUp: () => void; + onRetry: () => void; +}>; + +type BoundaryState = { code?: SqliteClientErrorCode }; + +export class OfflineDbBoundary extends React.Component { + state: BoundaryState = {}; + + // Must return state, and render() must stop rendering the failing subtree. Returning + // null here would re-render the same children, they would throw again, and React + // would give up and unmount the whole app. + static getDerivedStateFromError(error: unknown) { + const code = (error as SqliteClientError | undefined)?.code; + if (!code) { + throw error; + } + return { code }; + } + + componentDidCatch(error: unknown) { + // Discriminated on `code` rather than `instanceof`: a string comparison cannot be + // defeated by two copies of the class ending up in one bundle. + const code = (error as SqliteClientError | undefined)?.code; + + if (code === 'OFFLINE_DB_UNREADABLE') { + // The recommended recovery: the contents are a cache, so drop the database and + // let it rebuild. Only actions queued while offline are lost. + try { + SqliteClient.deleteDatabase(); + } catch (deleteError) { + console.warn('[SampleApp] could not delete the offline database', deleteError); + } + this.props.onRetry(); + return; + } + + // No usable key, so a new database would be plaintext. Run online-only instead. + console.warn(`[SampleApp] offline encryption unavailable (${code}); going online-only`); + this.props.onGiveUp(); + } + + render() { + return this.state.code ? null : this.props.children; + } +} From 29d6a0e114a44d47280af86ff5d902261023ef21 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 20 Aug 2026 13:03:42 +0200 Subject: [PATCH 6/6] fix: pr remarks --- .../src/components/OfflineDbBoundary.tsx | 20 ++++++------- package/src/components/Chat/Chat.tsx | 16 ++-------- .../components/Chat/__tests__/Chat.test.tsx | 30 +++++++++++-------- .../Chat/hooks/useInitializeOfflineDb.ts | 2 +- package/src/store/SqliteClient.ts | 21 +++++++------ 5 files changed, 43 insertions(+), 46 deletions(-) diff --git a/examples/SampleApp/src/components/OfflineDbBoundary.tsx b/examples/SampleApp/src/components/OfflineDbBoundary.tsx index c332e53b39..d58806ddbb 100644 --- a/examples/SampleApp/src/components/OfflineDbBoundary.tsx +++ b/examples/SampleApp/src/components/OfflineDbBoundary.tsx @@ -19,8 +19,8 @@ import { * * The `onGiveUp` path covers the codes that mean "no usable encryption key" * (`SQLCIPHER_BUILD_MISSING`, `ENCRYPTION_KEY_UNAVAILABLE`). Those only occur when - * `` is given a `getEncryptionKey` prop, which this sample does not do - a new - * database would then be written in plaintext, so running online-only is the safe + * `` is given a `getOfflineDbEncryptionKey` prop, which this sample does not do - + * a new database would then be written in plaintext, so running online-only is the safe * response. */ type BoundaryProps = React.PropsWithChildren<{ @@ -37,19 +37,19 @@ export class OfflineDbBoundary extends React.Component & closeConnectionOnBackground?: boolean; /** * Enables offline storage and loading for chat data. - * - * **Wrap `` in an error boundary.** If the database on disk cannot be read - - * corruption, or an encrypted database left behind after {@link getEncryptionKey} - * was removed - `` throws a {@link SqliteClientError} with code - * `OFFLINE_DB_UNREADABLE` from render. The SDK never deletes it for you; recover - * with `SqliteClient.deleteDatabase()` and re-mount, which rebuilds from the - * server. Prior to this the same situation left offline support uninitialized and - * `` rendering `ChatLoadingIndicator` indefinitely, with no way to react. */ enableOfflineSupport?: boolean; /** @@ -87,8 +79,6 @@ export type ChatProps = Pick & * recovery: re-mount with `enableOfflineSupport={false}`** so nothing is * persisted unencrypted. * - * `examples/SampleApp` implements all three. - * * The key must be **stable for the lifetime of the database file**. There is no * rekey path, so a key that changes costs one `OFFLINE_DB_UNREADABLE` and a * rebuild. To rotate without paying that, rotate a key-encryption key and keep the @@ -98,7 +88,7 @@ export type ChatProps = Pick & * disk and so raises `OFFLINE_DB_UNREADABLE` once in each direction. Deleting it * from your boundary is all that is needed. */ - getEncryptionKey?: () => Promise; + getOfflineDbEncryptionKey?: () => Promise; /** * Optional positive cap on the number of events a single `/sync` response may * contain before the offline sync manager skips replaying those events into @@ -226,7 +216,7 @@ const ChatWithContext = (props: PropsWithChildren) => { client, closeConnectionOnBackground = true, enableOfflineSupport = false, - getEncryptionKey, + getOfflineDbEncryptionKey, i18nInstance, isMessageAIGenerated, maxSyncEventsLimit = DEFAULT_MAX_SYNC_EVENTS_LIMIT, @@ -299,7 +289,7 @@ const ChatWithContext = (props: PropsWithChildren) => { useInitializeOfflineDb({ client, enabled: enableOfflineSupport, - options: { getEncryptionKey, maxSyncEventsLimit }, + options: { getEncryptionKey: getOfflineDbEncryptionKey, maxSyncEventsLimit }, userID, }); diff --git a/package/src/components/Chat/__tests__/Chat.test.tsx b/package/src/components/Chat/__tests__/Chat.test.tsx index c6074f43b2..8862b49657 100644 --- a/package/src/components/Chat/__tests__/Chat.test.tsx +++ b/package/src/components/Chat/__tests__/Chat.test.tsx @@ -432,19 +432,23 @@ describe('Chat offline DB encryption', () => { expect(SqliteClient.getEncryptionKey).toBeUndefined(); }); - it('forwards getEncryptionKey to the sqlite client', async () => { + it('forwards getOfflineDbEncryptionKey to the sqlite client', async () => { const chatClientWithUser = await createClient(); - const getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + const getOfflineDbEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); render( - , + , ); await waitFor(() => expect(chatClientWithUser.offlineDb).toBeDefined()); - await waitFor(() => expect(getEncryptionKey).toHaveBeenCalled()); + await waitFor(() => expect(getOfflineDbEncryptionKey).toHaveBeenCalled()); }); - it('does not re-initialize when getEncryptionKey is a new function every render', async () => { + it('does not re-initialize when getOfflineDbEncryptionKey is a new function every render', async () => { const chatClientWithUser = await createClient(); const resolveKey = jest.fn().mockResolvedValue('a-stable-key'); @@ -454,7 +458,7 @@ describe('Chat offline DB encryption', () => { resolveKey()} + getOfflineDbEncryptionKey={() => resolveKey()} />, ); @@ -465,14 +469,14 @@ describe('Chat offline DB encryption', () => { resolveKey()} + getOfflineDbEncryptionKey={() => resolveKey()} />, ); rerender( resolveKey()} + getOfflineDbEncryptionKey={() => resolveKey()} />, ); @@ -495,7 +499,7 @@ describe('Chat offline DB encryption', () => { const { getByTestId } = render( - + , @@ -520,7 +524,7 @@ describe('Chat offline DB encryption', () => { Promise.resolve('a-stable-key')} + getOfflineDbEncryptionKey={() => Promise.resolve('a-stable-key')} /> , ); @@ -548,7 +552,7 @@ describe('Chat offline DB encryption', () => { Promise.resolve('a-stable-key')} + getOfflineDbEncryptionKey={() => Promise.resolve('a-stable-key')} /> , ); @@ -571,7 +575,7 @@ describe('Chat offline DB encryption', () => { Promise.resolve(undefined)} + getOfflineDbEncryptionKey={() => Promise.resolve(undefined)} /> , ); @@ -594,7 +598,7 @@ describe('Chat offline DB encryption', () => { Promise.resolve('a-stable-key')} + getOfflineDbEncryptionKey={() => Promise.resolve('a-stable-key')} > diff --git a/package/src/components/Chat/hooks/useInitializeOfflineDb.ts b/package/src/components/Chat/hooks/useInitializeOfflineDb.ts index 47fcf28ab9..04860a28e2 100644 --- a/package/src/components/Chat/hooks/useInitializeOfflineDb.ts +++ b/package/src/components/Chat/hooks/useInitializeOfflineDb.ts @@ -10,7 +10,7 @@ export type InitializeOfflineDbOptions = { /** * Encrypts the offline database at rest with SQLCipher, using the key this resolves * to. Leaving it unset opens the database unencrypted, which is the default. See - * `ChatProps.getEncryptionKey` for the build flag it requires, the stability + * `ChatProps.getOfflineDbEncryptionKey` for the build flag it requires, the stability * requirement, and how failures are surfaced. */ getEncryptionKey?: () => Promise; diff --git a/package/src/store/SqliteClient.ts b/package/src/store/SqliteClient.ts index ddca086034..22b532e971 100644 --- a/package/src/store/SqliteClient.ts +++ b/package/src/store/SqliteClient.ts @@ -61,8 +61,9 @@ export class SqliteClient { /** * Supplies the SQLCipher key the offline database is opened with; `undefined` * opens it unencrypted, which is the default. The key must be stable for the - * lifetime of the database file - there is no rekey path, so a database it cannot - * decrypt is wiped and rebuilt from the server. + * lifetime of the database file - there is no rekey path, so a database this key + * cannot read raises `OFFLINE_DB_UNREADABLE` on the first page read. The file is + * left untouched; recovery is `SqliteClient.deleteDatabase()` and a re-mount. */ static getEncryptionKey: (() => Promise) | undefined; @@ -138,16 +139,17 @@ export class SqliteClient { if (sqlite === undefined) { throw new SqliteClientError( 'SQLCIPHER_BUILD_MISSING', - 'getEncryptionKey was provided but "@op-engineering/op-sqlite" is not installed.', + 'An offline database encryption key was provided but "@op-engineering/op-sqlite" ' + + 'is not installed.', ); } if (typeof sqlite.isSQLCipher !== 'function' || !sqlite.isSQLCipher()) { throw new SqliteClientError( 'SQLCIPHER_BUILD_MISSING', - 'getEncryptionKey was provided but @op-engineering/op-sqlite was not built with ' + - 'SQLCipher, so the key would be silently ignored and the offline database ' + - 'written in plaintext. Add { "op-sqlite": { "sqlcipher": true } } to your ' + - "application's package.json and rebuild, or remove getEncryptionKey.", + 'An offline database encryption key was provided but @op-engineering/op-sqlite was ' + + 'not built with SQLCipher, so the key would be silently ignored and the offline ' + + 'database written in plaintext. Add { "op-sqlite": { "sqlcipher": true } } to your ' + + "application's package.json and rebuild, or stop providing a key.", ); } @@ -158,7 +160,7 @@ export class SqliteClient { } catch (error) { throw new SqliteClientError( 'ENCRYPTION_KEY_UNAVAILABLE', - 'getEncryptionKey threw, so the offline database cannot be opened.', + 'The offline database encryption key getter threw, so the database cannot be opened.', { cause: error }, ); } @@ -168,7 +170,8 @@ export class SqliteClient { if (!encryptionKey) { throw new SqliteClientError( 'ENCRYPTION_KEY_UNAVAILABLE', - 'getEncryptionKey resolved without a key, so the offline database cannot be opened.', + 'The offline database encryption key getter resolved without a key, so the database ' + + 'cannot be opened.', ); }