Skip to content

Commit c780d12

Browse files
committed
Merge remote-tracking branch 'origin/main'
2 parents 42d81f2 + ad95af5 commit c780d12

32 files changed

Lines changed: 1301 additions & 192 deletions

packages/wallet/ts-wallet-relay/API.md

Lines changed: 95 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@
2525
- [encryptEnvelope](#encryptenvelope)
2626
- [decryptEnvelope](#decryptenvelope)
2727
- [bytesToBase64url / base64urlToBytes](#bytestobase64url--base64urltobytes)
28+
- [compileOriginMatcher](#compileoriginmatcher)
2829
- [Types](#types)
30+
- [AllowedOrigins](#allowedorigins)
2931
- [WalletRequest / WalletResponse / RequestLogEntry](#walletrequest--walletresponse--requestlogentry)
3032

3133
---
@@ -46,11 +48,12 @@ new WalletRelayService(options: WalletRelayServiceOptions)
4648

4749
| Option | Type | Required | Default | Description |
4850
|--------|------|----------|---------|-------------|
49-
| `app` | `RouterLike` | No || Express-compatible app with `get` and `post` methods. REST routes are registered on it. Omit when using Next.js or another framework — call `createSession()`, `getSession()`, and `sendRequest()` from your own route handlers instead. Uses a structural duck-type to avoid nominal type conflicts in monorepos. |
51+
| `app` | `RouterLike` | No || Express-compatible app with `get`, `post`, and `delete` methods. REST routes are registered on it. Omit when using Next.js or another framework — call `createSession()`, `getSession()`, `sendRequest()`, and `deleteSession()` from your own route handlers instead. Uses a structural duck-type to avoid nominal type conflicts in monorepos. |
5052
| `server` | `http.Server` | **Yes** || HTTP server. The WebSocket upgrade handler is attached here. |
5153
| `wallet` | `WalletLike` | **Yes** || Backend wallet for encrypting/decrypting messages. Use `ProtoWallet` with a stable private key: `new ProtoWallet(PrivateKey.fromHex(process.env.WALLET_PRIVATE_KEY!))`. The same key must be used across restarts — the mobile derives its ECDH shared secret from the backend identity key embedded in the QR code. |
5254
| `relayUrl` | `string` | No | `process.env.RELAY_URL``ws://localhost:3000` | `ws://` or `wss://` base URL of this server. Returned by `GET /api/session/:id` so the mobile can resolve it after scanning the QR. Not embedded in the QR itself. |
53-
| `origin` | `string` | No | `process.env.ORIGIN``http://localhost:5173` | `http://` or `https://` URL of the backend API root. Used for WebSocket origin validation and embedded in the QR pairing URI. The mobile calls `{origin}/api/session/{topic}` over HTTPS to resolve the relay URL — this is the trust anchor. In production this is your app domain. In local dev with a split Vite/Node setup, set this to the backend's LAN address so the mobile device can reach it (see `MOBILE_ORIGIN` in the quickstart). |
55+
| `origin` | `string` | No | `process.env.ORIGIN``http://localhost:5173` | Default `http://` or `https://` URL embedded in the QR pairing URI when `createSession()` is called without a per-session `origin` override. The mobile calls `{origin}/api/session/{topic}` over HTTPS to resolve the relay URL — this is the trust anchor. In production this is your app domain. For multi-app deployments (one relay shared by N webapps) leave this unset or set a sensible fallback, and pass `origin` per-call to `createSession({ origin })` instead. In local dev with a split Vite/Node setup, set this to the backend's LAN address so the mobile device can reach it (see `MOBILE_ORIGIN` in the quickstart). |
56+
| `allowedOrigins` | `AllowedOrigins` | No | `origin` (legacy fallback) | Origin allowlist controlling (a) which origins may be claimed by callers of `createSession({ origin })`, and (b) which browser origins may open a desktop-role WebSocket. Accepts a `string`, `string[]`, `RegExp`, or `(origin: string) => boolean` predicate. When unset, falls back to the single-value `origin` for backward compatibility. Required for multi-app deployments. |
5457
| `maxSessions` | `number` | No | unlimited | Maximum number of sessions held in memory at once. `GET /api/session` returns HTTP 429 when the limit is reached. |
5558
| `schema` | `string` | No | `process.env.PAIRING_SCHEMA``'bsv-browser'` | Deep-link scheme used in the generated QR URI (without `://`). Defaults to `'bsv-browser'`. Set to your wallet's own scheme (e.g. `'bsv-browser'`, `'my-wallet'`) to target a specific app — the OS will open that app directly instead of showing a picker when multiple wallets are installed. The mobile app must register this scheme and pass it to `parsePairingUri` via `acceptedSchemas`. |
5659
| `signQrCodes` | `boolean` | No | `true` | Sign the QR pairing URI with the backend wallet key. The mobile can verify the signature using `verifyPairingSignature` before connecting — this proves the QR fields have not been tampered with. Set to `false` only for backward compatibility with mobile apps that do not yet call `verifyPairingSignature`. |
@@ -59,10 +62,10 @@ new WalletRelayService(options: WalletRelayServiceOptions)
5962

6063
#### Methods
6164

62-
**`createSession()`**
65+
**`createSession(options?)`**
6366

6467
```ts
65-
createSession(): Promise<{
68+
createSession(options?: { origin?: string }): Promise<{
6669
sessionId: string
6770
status: string
6871
qrDataUrl: string
@@ -75,6 +78,8 @@ Creates a new pairing session, builds the `wallet://pair?…` URI, and generates
7578

7679
The `desktopToken` is a cryptographically random secret that must be passed as a `?token=` query parameter when the desktop opens its WebSocket connection (`role=desktop`). Keep it server-side — do not embed it in the QR code or share it with the mobile.
7780

81+
Pass `options.origin` to embed a per-session origin in the QR — used by multi-app deployments where the caller's URL (not the relay's) is the trust anchor. When omitted, the constructor `origin` is used. The built-in `GET /api/session` route forwards the request's `Origin` header automatically. If `allowedOrigins` is configured, a per-session origin that does not match throws (and the route responds with `403`).
82+
7883
---
7984

8085
**`getSession(id)`**
@@ -99,6 +104,20 @@ Encrypts an RPC call, sends it to the paired mobile wallet over WebSocket, and w
99104

100105
---
101106

107+
**`deleteSession(sessionId, desktopToken)`**
108+
109+
```ts
110+
deleteSession(sessionId: string, desktopToken: string): void
111+
```
112+
113+
Terminates a session from the desktop side: closes the mobile's WebSocket (the mobile app is notified and transitions to `'disconnected'`), rejects any in-flight RPC requests immediately, and marks the session `'expired'`.
114+
115+
Throws `'Session not found'` if the session does not exist, or `'Invalid desktop token'` if the token does not match the one issued at session creation.
116+
117+
The primary use case is **logout**: call this when the user ends their app session so the mobile is notified immediately rather than waiting for the server-side TTL to expire. If you are using `useWalletRelayClient`, call `cancelSession()` instead — it calls `disconnect()` on the client, which sends this request automatically.
118+
119+
---
120+
102121
**`stop()`**
103122

104123
```ts
@@ -111,14 +130,19 @@ Stops the session GC timer and closes the WebSocket server. Call on process shut
111130

112131
#### Registered routes
113132

114-
| Method | Path | Body | Response |
115-
|--------|------|------|----------|
133+
| Method | Path | Body / Auth | Response |
134+
|--------|------|-------------|----------|
116135
| `GET` | `/api/session` || `{ sessionId, status, qrDataUrl, pairingUri, desktopToken }` |
117136
| `GET` | `/api/session/:id` || `{ sessionId, status, relay }` |
118-
| `POST` | `/api/request/:id` | `{ method: string, params: unknown }` | `RpcResponse` |
137+
| `POST` | `/api/request/:id` | Body: `{ method, params }` · Header: `X-Desktop-Token` | `RpcResponse` |
138+
| `DELETE` | `/api/session/:id` | Header: `X-Desktop-Token` | `204 No Content` |
139+
140+
`GET /api/session` automatically forwards the request's `Origin` header into `createSession({ origin })`, so the QR points back at the calling webapp rather than the relay's own URL. Returns `403` when the claimed origin is not in `allowedOrigins`, and `429` when `maxSessions` is reached.
119141

120142
`GET /api/session/:id` is called by the mobile app after scanning the QR to resolve the relay WebSocket URL. The mobile trusts this response because it is served over HTTPS from the origin embedded in the QR.
121143

144+
`DELETE /api/session/:id` closes the mobile's WebSocket connection server-side. The mobile app receives a close event and knows the session has ended. Returns `401` if the token is missing or wrong, `404` if the session does not exist.
145+
122146
---
123147

124148
### WalletRelayClient
@@ -225,13 +249,25 @@ try {
225249

226250
---
227251

252+
**`disconnect()`**
253+
254+
```ts
255+
disconnect(): Promise<void>
256+
```
257+
258+
Terminates the session server-side then cleans up locally. Sends `DELETE /api/session/:id` with the desktop token so the backend closes the mobile's WebSocket and marks the session expired. Errors from the server call are swallowed — local teardown always completes.
259+
260+
Prefer `disconnect()` over `destroy()` when you want the mobile app to be notified that the session has ended (for example, on user logout). Falls back to `destroy()`-only behaviour if there is no active session or desktop token.
261+
262+
---
263+
228264
**`destroy()`**
229265

230266
```ts
231267
destroy(): void
232268
```
233269

234-
Stops the polling interval. Call on component unmount or teardown.
270+
Stops the polling interval and clears the desktop token. Local cleanup only — the server session remains active and the mobile is not notified. Prefer `disconnect()` when the mobile should be notified.
235271

236272
---
237273

@@ -445,7 +481,7 @@ Peer dependency: `react >= 17`
445481
React hook wrapping [`WalletRelayClient`](#walletrelayclient) with React state. The primary integration point for web apps — replaces the scaffolded `useWalletSession` template hook.
446482

447483
```tsx
448-
const { session, log, error, createSession, sendRequest } = useWalletRelayClient()
484+
const { session, log, error, createSession, cancelSession, sendRequest } = useWalletRelayClient()
449485
```
450486

451487
#### Options
@@ -461,6 +497,7 @@ All options are optional.
461497
| `sessionStorageKey` | `string` | `'wallet-relay-session:<apiUrl>'` | Override the storage key. |
462498
| `sessionStorageTtl` | `number` | `86400000` | Max age (ms) before a persisted session is discarded client-side. |
463499
| `autoCreate` | `boolean` | `true` | When `true`, `resumeSession()` is tried on mount, falling back to `createSession()` if nothing to resume. Set to `false` to control timing manually. |
500+
| `autoResume` | `boolean` | `false` | When `true` *and* `autoCreate` is `false`, attempts `resumeSession()` on mount but never auto-creates. Useful when a "Sign in with phone" button owns session creation while you still want refreshes to keep the user paired. No effect when `autoCreate !== false` — resume is already part of that path. |
464501

465502
#### Return value
466503

@@ -470,6 +507,8 @@ All options are optional.
470507
| `log` | `RequestLogEntry[]` | Request history, newest first. |
471508
| `error` | `string \| null` | Error from the last failed `createSession()`, or `null`. |
472509
| `createSession` | `() => Promise<SessionInfo>` | Create a new session and restart polling. Safe to call multiple times — replaces the existing session. |
510+
| `resumeSession` | `() => Promise<SessionInfo \| null>` | Try to resume a persisted session from `sessionStorage`. Returns the resumed `SessionInfo` (which exposes the `wallet` proxy when status is `'connected'`), or `null` if nothing to resume or the server says it's expired. Concurrent calls are deduped — the second caller gets the in-flight promise. |
511+
| `cancelSession` | `() => void` | Resets all state to `null`, then calls `disconnect()` on the client (fire-and-forget). This terminates the session server-side and closes the mobile's WebSocket so the mobile app is notified. Call this on unmount when leaving a QR page, or on user logout. A subsequent `createSession()` starts fresh. |
473512
| `sendRequest` | `(method: string, params?: unknown) => Promise<WalletResponse>` | Send an RPC call to the paired mobile. Throws if no session is active. |
474513
| `wallet` | `Pick<WalletInterface, WalletMethodName> \| null` | Drop-in `WalletInterface` proxy when connected, `null` otherwise. See [wallet proxy](#wallet-proxy). |
475514

@@ -734,12 +773,20 @@ Topic-keyed WebSocket bridge. Mounts at `/ws`. Connections use `?topic=<sessionI
734773
#### Constructor
735774

736775
```ts
737-
new WebSocketRelay(server: http.Server, options?: { allowedOrigin?: string })
776+
new WebSocketRelay(server: http.Server, options?: WebSocketRelayOptions)
777+
778+
interface WebSocketRelayOptions {
779+
allowedOrigin?: string // legacy single-string match — kept for backward compatibility
780+
allowedOrigins?: AllowedOrigins // string | string[] | RegExp | (origin: string) => boolean
781+
}
738782
```
739783

740784
| Option | Type | Description |
741785
|--------|------|-------------|
742-
| `allowedOrigin` | `string` | If set, incoming WebSocket connections that include an `Origin` header must match this value exactly or the connection is rejected with close code `1008`. Browser clients always send `Origin` and cannot spoof it. Native clients (React Native, server-to-server) omit the header and are exempt. |
786+
| `allowedOrigins` | `AllowedOrigins` | Origin allowlist for browser WS upgrades (`role=desktop`). Accepts a `string`, `string[]`, `RegExp`, or `(origin: string) => boolean` predicate. Connections whose `Origin` header does not match are rejected with close code `1008`. Native clients (React Native, server-to-server) omit `Origin` and are exempt. |
787+
| `allowedOrigin` | `string` | Legacy single-value form, kept for backward compatibility. Equivalent to passing the same string as `allowedOrigins`. If both are set, `allowedOrigins` takes precedence. |
788+
789+
When neither option is set, no origin validation runs.
743790

744791
#### Methods
745792

@@ -1105,6 +1152,20 @@ Converts between `number[]` byte arrays and base64url strings using `@bsv/sdk`'s
11051152

11061153
---
11071154

1155+
### compileOriginMatcher
1156+
1157+
```ts
1158+
function compileOriginMatcher(
1159+
allowed: AllowedOrigins | undefined | null
1160+
): ((origin: string) => boolean) | null
1161+
```
1162+
1163+
Compiles an [`AllowedOrigins`](#allowedorigins) declaration into a single predicate. Returns `null` when `allowed` is `null` / `undefined` (treated by callers as "allow all"). Used internally by `WalletRelayService` and `WebSocketRelay`; exported for advanced compositions where the same matcher logic is needed in custom routes or middleware.
1164+
1165+
Available from `@bsv/wallet-relay` (server entry).
1166+
1167+
---
1168+
11081169
## Types
11091170

11101171
All types are exported from both `@bsv/wallet-relay` and `@bsv/wallet-relay/client`.
@@ -1121,6 +1182,29 @@ Minimal wallet interface required by the library. Satisfied by `ProtoWallet` and
11211182

11221183
---
11231184

1185+
### AllowedOrigins
1186+
1187+
```ts
1188+
type AllowedOrigins =
1189+
| string
1190+
| string[]
1191+
| RegExp
1192+
| ((origin: string) => boolean)
1193+
```
1194+
1195+
Origin allowlist shape accepted by `WalletRelayService.allowedOrigins` and `WebSocketRelay.allowedOrigins`.
1196+
1197+
| Form | Match rule |
1198+
|------|-----------|
1199+
| `string` | exact equality |
1200+
| `string[]` | membership in the list |
1201+
| `RegExp` | `pattern.test(origin)` (e.g. `/\.example\.com$/`) |
1202+
| `(origin) => boolean` | custom predicate |
1203+
1204+
Compile a declaration into a single predicate with [`compileOriginMatcher`](#compileoriginmatcher).
1205+
1206+
---
1207+
11241208
### WireEnvelope
11251209

11261210
```ts

0 commit comments

Comments
 (0)