Skip to content
create-bsv-app

Capabilities

The three BSV building blocks create-bsv-app wires into your app, the APIs they give you, and the one proof mechanism behind all of them.

A capability is a small set of readable TypeScript files dropped into src/bsv/, plus the dependencies, routes and providers they need. Pick them with --capabilities a,b or in the prompts.

Capability Requires Client Server
wallet-connect (always on) nothing useWallet() React context
<ConnectWallet /> button and fallback dialog
apiClient.ts: bounded, redirect-free fetch
getServerIdentity()
GET /api/identity
WalletRelayService: /api/session and /ws for mobile QR pairing
wallet-login wallet-connect /login page
useWalletLogin() hook
POST /api/login via loginRoute(serverWallet)
signed-requests wallet-connect /signed-demo page
useSignedRequest() and signedFetch()
POST /api/echo
verifySignedRequest(), framework-agnostic

Dependencies are resolved for you: asking for wallet-login pulls in wallet-connect.

wallet-connect#

Connect any BRC-100 wallet and use it anywhere in your React tree. It's always installed in new projects.

tsx
import { useWallet } from './bsv/WalletContext'

const { status, connected, wallet, identityKey, connect, connectMobile, cancel } = useWallet()
Field Type What it is
status 'disconnected' | 'connecting' | 'choosing' | 'pairing' | 'connected' where the connect flow is
connected boolean wallet !== null
wallet WalletInterface | null the full BRC-100 wallet from @bsv/sdk
identityKey string | null the user's public identity key (hex)
connect() () => Promise<void> try the desktop wallet; on failure go to choosing
connectMobile() () => Promise<void> start QR pairing (pairing)
cancel() () => void back to disconnected

The flow is a small state machine:

connect flow
disconnected ──connect()──▶ connecting ──desktop wallet found──▶ connected
                                │
                                └─ none found ─▶ choosing ──connectMobile()──▶ pairing ──QR scanned──▶ connected

<ConnectWallet /> renders all of this for you: the button, the No desktop wallet found dialog and the QR code. Use it as is, restyle it, or build your own UI on useWallet().

wallet-login#

Passwordless login. The wallet signs a proof with action: 'login', the server verifies it, and you get an identityKey you can trust.

import { useWalletLogin } from './bsv/useWalletLogin'

const { login } = useWalletLogin()
const { identityKey } = await login()

useWalletLogin() takes optional { serverIdentityKey, loginEndpoint }. Pass serverIdentityKey to pin the server's key instead of fetching it. Here's why you might.

Turning login into a session#

The scaffold stops at "this identity key is proven". It doesn't pick a session strategy for you. Here's a pattern we've tested end to end: exchange the login proof for a short-lived JWT, then send it as a bearer header.

// Turn a verified wallet login into a short-lived bearer token.
import type { NextFunction, interface Request<P = core.ParamsDictionary, ResBody = any, ReqBody = any, ReqQuery = QueryString.ParsedQs, Locals extends Record<string, any> = Record<string, any>>Request, interface Response<ResBody = any, Locals extends Record<string, any> = Record<string, any>>Response } from 'express'
import { class SignJWTSignJWT, function jwtVerify<PayloadType = JWTPayload>(jwt: string | Uint8Array, key: KeyInput, options?: JWTVerifyOptions): Promise<JWTVerifyResult<PayloadType>> (+2 overloads)jwtVerify } from 'jose'
import { 
function verifyAuthProof(serverWallet: {
    verifySignature: (args: any) => Promise<{
        valid: boolean;
    }>;
}, proof: AuthProof, opts: {
    action: string;
    body?: RequestBody;
}, consumeNonce: (nonce: string, expiresAt: Date) => boolean | Promise<boolean>): Promise<{
    valid: boolean;
    identityKey?: string;
    error?: string;
}>
verifyAuthProof
} from './bsv/auth.js'
import { function consumeNonce(nonce: string, expiresAt: Date): booleanconsumeNonce } from './bsv/nonceStore.js' type
type ServerWallet = {
    verifySignature: (args: any) => Promise<{
        valid: boolean;
    }>;
}
ServerWallet
= type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : neverParameters<typeof
function verifyAuthProof(serverWallet: {
    verifySignature: (args: any) => Promise<{
        valid: boolean;
    }>;
}, proof: AuthProof, opts: {
    action: string;
    body?: RequestBody;
}, consumeNonce: (nonce: string, expiresAt: Date) => boolean | Promise<boolean>): Promise<{
    valid: boolean;
    identityKey?: string;
    error?: string;
}>
verifyAuthProof
>[0]
const const secretText: string | undefinedsecretText = var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvenv.string | undefinedJWT_SECRET if (const secretText: string | undefinedsecretText == null || new var TextEncoder: new () => TextEncoderTextEncoder().TextEncoder.encode(input?: string): Uint8Array<ArrayBuffer>encode(const secretText: stringsecretText).Uint8Array<ArrayBuffer>.byteLength: numberbyteLength < 32) { throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
('JWT_SECRET must contain at least 32 bytes')
} const const secret: Uint8Array<ArrayBuffer>secret = new var TextEncoder: new () => TextEncoderTextEncoder().TextEncoder.encode(input?: string): Uint8Array<ArrayBuffer>encode(const secretText: stringsecretText) /** POST /api/session-login: verify a `login` proof, return { token, identityKey }. */ export function function sessionLogin(serverWallet: ServerWallet): (req: Request, res: Response) => Promise<void>sessionLogin (
serverWallet: {
    verifySignature: (args: any) => Promise<{
        valid: boolean;
    }>;
}
serverWallet
:
type ServerWallet = {
    verifySignature: (args: any) => Promise<{
        valid: boolean;
    }>;
}
ServerWallet
) {
return async (req: Request<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>req: interface Request<P = core.ParamsDictionary, ResBody = any, ReqBody = any, ReqQuery = QueryString.ParsedQs, Locals extends Record<string, any> = Record<string, any>>Request, res: Response<any, Record<string, any>>res: interface Response<ResBody = any, Locals extends Record<string, any> = Record<string, any>>Response): interface Promise<T>Promise<void> => { const
const result: {
    valid: boolean;
    identityKey?: string;
    error?: string;
}
result
= await
function verifyAuthProof(serverWallet: {
    verifySignature: (args: any) => Promise<{
        valid: boolean;
    }>;
}, proof: AuthProof, opts: {
    action: string;
    body?: RequestBody;
}, consumeNonce: (nonce: string, expiresAt: Date) => boolean | Promise<boolean>): Promise<{
    valid: boolean;
    identityKey?: string;
    error?: string;
}>
verifyAuthProof
(
serverWallet: {
    verifySignature: (args: any) => Promise<{
        valid: boolean;
    }>;
}
serverWallet
, req: Request<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>req.Request<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>.body: anybody, { action: stringaction: 'login' }, function consumeNonce(nonce: string, expiresAt: Date): booleanconsumeNonce)
if (!
const result: {
    valid: boolean;
    identityKey?: string;
    error?: string;
}
result
.valid: booleanvalid ||
const result: {
    valid: boolean;
    identityKey?: string;
    error?: string;
}
result
.identityKey?: string | undefinedidentityKey == null) {
res: Response<any, Record<string, any>>res.Response<any, Record<string, any>, number>.status(code: number): Response<any, Record<string, any>>status(401).Response<any, Record<string, any>, number>.json: (body?: any) => Response<any, Record<string, any>>json({ error: stringerror: 'invalid proof' }) return } const const token: stringtoken = await new new SignJWT(payload?: JWTPayload): SignJWTSignJWT({ JWTPayload.sub?: string | undefinedsub:
const result: {
    valid: boolean;
    identityKey?: string;
    error?: string;
}
result
.identityKey?: stringidentityKey })
.SignJWT.setProtectedHeader(protectedHeader: JWTHeaderParameters): SignJWTsetProtectedHeader({ CompactJWSHeaderParameters.alg: JWSAlgorithmalg: 'HS256' }) .ProduceJWT.setIssuedAt(input?: number | string | Date): SignJWTsetIssuedAt() .ProduceJWT.setExpirationTime(input: number | string | Date): SignJWTsetExpirationTime('1h') .SignJWT.sign(key: KeyInput, options?: SignOptions): Promise<string>sign(const secret: Uint8Array<ArrayBuffer>secret) res: Response<any, Record<string, any>>res.Response<any, Record<string, any>, number>.json: (body?: any) => Response<any, Record<string, any>>json({ token: stringtoken, identityKey: stringidentityKey:
const result: {
    valid: boolean;
    identityKey?: string;
    error?: string;
}
result
.identityKey?: stringidentityKey })
} } /** Middleware: require `Authorization: Bearer <token>`; exposes res.locals.identityKey. */ export async function function requireSession(req: Request, res: Response, next: NextFunction): Promise<void>requireSession (req: Request<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>req: interface Request<P = core.ParamsDictionary, ResBody = any, ReqBody = any, ReqQuery = QueryString.ParsedQs, Locals extends Record<string, any> = Record<string, any>>Request, res: Response<any, Record<string, any>>res: interface Response<ResBody = any, Locals extends Record<string, any> = Record<string, any>>Response, next: NextFunctionnext: NextFunction): interface Promise<T>Promise<void> { const const token: string | undefinedtoken = req: Request<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>req.Request<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>.get(name: string): string | undefined (+1 overload)get('authorization')?.String.replace(searchValue: string | RegExp, replaceValue: string): string (+3 overloads)replace(/^Bearer /, '') try { const { const payload: JWTPayloadpayload } = await jwtVerify<JWTPayload>(jwt: string | Uint8Array, key: KeyInput, options?: JWTVerifyOptions): Promise<JWTVerifyResult<JWTPayload>> (+2 overloads)jwtVerify(const token: string | undefinedtoken ?? '', const secret: Uint8Array<ArrayBuffer>secret, { VerifyOptions.algorithms?: JWSAlgorithm[] | undefinedalgorithms: ['HS256'] }) res: Response<any, Record<string, any>>res.Response<any, Record<string, any>, number>.locals: Record<string, any> & Localslocals.identityKey = const payload: JWTPayloadpayload.JWTPayload.sub?: string | undefinedsub
next: NextFunction
(err?: any) => void (+2 overloads)
next
()
} catch { res: Response<any, Record<string, any>>res.Response<any, Record<string, any>, number>.status(code: number): Response<any, Record<string, any>>status(401).Response<any, Record<string, any>, number>.json: (body?: any) => Response<any, Record<string, any>>json({ error: stringerror: 'not logged in' }) } }

Install jose in the server (npm i jose), and set JWT_SECRET to 32+ random bytes. node -e "console.log(crypto.randomBytes(32).toString('base64url'))" makes one.

signed-requests#

Authenticate a single API call, with no session at all. The proof is bound to an action name and the exact request body.

import { useSignedRequest } from './bsv/useSignedRequest'

const { signedFetch } = useSignedRequest()
const res = await signedFetch('/api/notes', { action: 'create-note', body: { text: 'gm' } })

signedFetch(path, { action, body }) always sends POST with { proof, body } as JSON. verifySignedRequest is a plain function, so it works the same in Express, Fastify, Hono or a Next.js route handler.

When to use which? Use login plus a session for an app people stay in. Use signed requests for individual high-value actions (posting, paying, voting, admin operations) and for machine-to-machine calls where there's no browser to hold a session.

How the proof works#

All three capabilities share one primitive from @bsv/auth (opens in a new tab) (BRC-103 style mutual authentication, simplified to a single message):

  1. The client fetches the server's public identity key from GET /api/identity. It becomes the proof's counterparty.
  2. The wallet signs { action, identityKey, expiresAt, nonce }, with the request body's exact JSON bytes appended when there is one. The signing key is derived between the user's identity and the server's, so a proof made for one server can't be replayed against another.
  3. The server checks the signature, checks expiresAt (proofs live 2 minutes by default, with 30 seconds allowed for clock skew), and calls consumeNonce, which refuses any nonce it has already seen.
  4. If all three checks pass, identityKey is cryptographically proven. If any check fails, you get { valid: false }.
Deep diveWhy "exact JSON bytes" matters

The client signs JSON.stringify(body), and the server verifies JSON.stringify of the body it parsed. These match as long as nothing rewrites the object in between. Express's JSON parser keeps key order, so it just works. If you add middleware that normalizes, sorts or coerces request bodies, run it after verification, not before.

Adding a capability later#

Terminal
npx create-bsv-app@latest add --capabilities signed-requests --yes

Run this inside the project. The new files are placed, dependencies are added and installed, and bsv-scaffold.json is updated. Your own files aren't rewritten. More on add mode.