# rtnbleserver A Bluetooth LE GATT server for React Native. The server authenticates a peer with a one-time password, exchanges DIDs, records the handshake time, and reports the result back to JavaScript. ## Installation ```sh npm install rtnbleserver ``` ## Usage ```ts import { getRecord, handleDID, setDID, setOTP, startGatt, stopGatt, } from 'rtnbleserver'; setOTP('123456'); setDID('did:plc:yourdid'); handleDID((record) => { console.log(record.did, record.createdAt); }); startGatt(); // Later, when the server is no longer needed: stopGatt(); ``` - `setOTP(otp)` sets the OTP a peer has to write before the server will send its DID. - `setDID(did)` sets the DID this device sends: to the peer when acting as a server, to the server when acting as a client. - `handleDID(callback)` registers the callback invoked when a handshake completes. The callback receives `{ did, createdAt }`. Acting as a server, `did` is the peer's DID; acting as a client, `did` is the server's DID. `createdAt` is always read from the clock of the device running the GATT server, as an ISO 8601 (RFC 3339) UTC timestamp. Registering a callback replaces the previous one. - `getRecord()` returns the last completed handshake as `{ did, createdAt }`, or `null` when no handshake has completed. Acting as a server, `did` is the peer's DID; acting as a client, `did` is the server's DID. - `startGatt()` starts the GATT server and advertising. - `stopGatt()` stops advertising, closes the server, and clears the session state. The last record is kept. ### Client The same module can act as the GATT client. It never starts a GATT server: it scans for the service, authenticates with the OTP, sends the DID from `setDID`, and reports the DID and timestamp it received from the server. ```ts setDID('did:plc:yourdid'); handleDID((record) => { console.log(record.did, record.createdAt); }); handleClientError((message) => { console.warn(message); }); startClient('123456'); ``` - `startClient(otp)` scans for the first device advertising the service, connects, enables notifications, writes `otp`, sends the DID from `setDID`, and then calls the `handleDID` callback with the server's DID and the timestamp recorded by the server. It throws when the DID is not set, Bluetooth is off, permissions are missing, or a client is already running. - `stopClient()` aborts the scan or connection. - `handleClientError(callback)` registers the callback for asynchronous failures: timeouts, disconnects, and scan failures. Registering a callback replaces the previous one. A client handshake gives up after 15 seconds. ## GATT protocol The server advertises one primary service. All UUIDs are fixed: | Purpose | UUID | | ------- | -------------------------------------- | | Service | `5f1d0000-9b3a-4c7e-8d2f-6a4b1c8e0f93` | | OTP | `5f1d0001-9b3a-4c7e-8d2f-6a4b1c8e0f93` | | DID | `5f1d0002-9b3a-4c7e-8d2f-6a4b1c8e0f93` | | Record | `5f1d0003-9b3a-4c7e-8d2f-6a4b1c8e0f93` | | Characteristic | Properties | Value | | -------------- | ------------------- | ---------------------------------------------------------------------- | | OTP | write | UTF-8 OTP digits. The write succeeds only when it matches `setOTP`. | | DID | read, write, notify | This device's DID (read/notify) and the peer's DID (write). | | Record | read, notify | `{"did":"","createdAt":""}` after the handshake. | Flow: 1. The peer connects and writes the OTP to the OTP characteristic. 2. On a match, the server marks the peer as authorized and notifies its own DID on the DID characteristic. A mismatch is rejected: Android cancels the connection, and iOS responds with an authorization error (CoreBluetooth cannot disconnect a central). 3. The peer writes its DID to the DID characteristic. 4. The server records the time from its own clock (the device running the GATT server, not the peer), notifies the record on the Record characteristic, and calls the `handleDID` callback. DID and record notifications are chunked so they work with the default 23-byte MTU. Each notification carries a two-byte header followed by up to 18 payload bytes: ``` [sequence:1][total:1][payload...] ``` Concatenate the payloads in sequence order. The DID and Record characteristics can also be read; reads return the complete value, so use a long read (read blob) when the value is longer than the negotiated MTU. Notifications require the peer to enable them (write `0x0001` to the standard CCCD, which the server exposes for the DID and Record characteristics). The client runs the complementary side: it subscribes to the DID and Record characteristics, writes the OTP, writes its own DID in frames, then reassembles the server's DID notification and the Record notification. Its `handleDID` callback combines the server's DID with the `createdAt` field of the record. ## Permissions Android: the library declares `BLUETOOTH_CONNECT`, `BLUETOOTH_ADVERTISE`, `BLUETOOTH_SCAN` (with `neverForLocation`), and `ACCESS_FINE_LOCATION` for Android 11 and below. The app has to request the runtime permissions it uses before calling `startGatt()` or `startClient()`, for example with `PermissionsAndroid`. On Android 11 and below, scanning also requires location services to be enabled. iOS: add `NSBluetoothAlwaysUsageDescription` to the app's `Info.plist`. The permission prompt appears when `startGatt()` creates the `CBPeripheralManager` or `startClient()` creates the `CBCentralManager`. Two synchronous checks report whether the runtime permissions are currently granted: ```ts import { hasClientPermissions, hasServerPermissions } from 'rtnbleserver'; hasServerPermissions(); // BLUETOOTH_CONNECT + BLUETOOTH_ADVERTISE hasClientPermissions(); // BLUETOOTH_SCAN + BLUETOOTH_CONNECT ``` The checks never prompt. On iOS, authorization is `notDetermined` until CoreBluetooth has been used, so both return `false` before the first `startGatt()` or `startClient()` call triggers the prompt. ## Using a local checkout in a host app A published install is a normal directory in `node_modules`. When a host app depends on this repository through a `file:` path, the package is symlinked and Metro resolves the sources to their real path. Point Metro at the checkout and keep peer dependencies in the host app so only one copy of React Native is bundled: ```js const path = require('node:path'); const config = getDefaultConfig(__dirname); config.watchFolders = [path.resolve(__dirname, '../RTNbleServer')]; config.resolver.nodeModulesPaths = [path.resolve(__dirname, 'node_modules')]; config.resolver.disableHierarchicalLookup = true; ``` With Bun, `file:` dependencies are installed as a tree of symlinks, which Expo's autolinking does not traverse. Add the checkout to the host app's autolinking search paths so the Android and iOS sources are discovered: ```json { "expo": { "autolinking": { "searchPaths": ["../RTNbleServer"] } } } ``` ## Development The library has no example app. See [CONTRIBUTING.md](CONTRIBUTING.md) for the standalone Android build and the JavaScript build. ## Contributing - [Development workflow](CONTRIBUTING.md#development-workflow) - [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request) - [Code of conduct](CODE_OF_CONDUCT.md) ## License MIT