Reference
Server API
The functions the scaffold writes to server/src/bsv/, the routes it mounts, and exactly what each one verifies, returns and refuses.
These live in server/src/bsv/ (or src/bsv/ for the express starter). Every verifier is a plain function, so it works in Express, Fastify, Hono or a Next.js route handler. The source of each one is at the end of its section, exactly as create-bsv-app 1.1.2 generates it.
Routes#
What a generated full-stack server answers, before you add your own:
| Route | From | Request | Response |
|---|---|---|---|
GET /health |
base | { "status": "ok" } |
|
GET /api/identity |
wallet-connect | { "identityKey": "03…" } |
|
GET /api/session, /ws |
wallet-connect | relay protocol | mobile QR pairing (@bsv/wallet-relay) |
POST /api/login |
wallet-login | an AuthProof |
{ identityKey }, or 401 { "error": "invalid proof" } |
POST /api/echo |
signed-requests | { proof, body } |
{ valid: true, identityKey }, or 401 |
Requests are parsed with express.json({ limit: '64kb', strict: true }), and CORS allows only CLIENT_ORIGIN.
verifySignedRequest()#
Verify one signed request. From signed-requests.
import { verifySignedRequest } from './bsv/verifySignedRequest.js'
import { consumeNonce } from './bsv/nonceStore.js'Usage#
import type { 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 { function verifySignedRequest(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;
}>
verifySignedRequest } from './bsv/verifySignedRequest.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 verifySignedRequest(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;
}>
verifySignedRequest>[0]
export const const createNote: (serverWallet: ServerWallet) => (req: Request, res: Response) => Promise<void>createNote = (serverWallet: {
verifySignature: (args: any) => Promise<{
valid: boolean;
}>;
}
serverWallet: type ServerWallet = {
verifySignature: (args: any) => Promise<{
valid: boolean;
}>;
}
ServerWallet) => 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) => {
const { const proof: anyproof, const body: anybody } = req: Request<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>req.Request<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>.body: anybody
const const result: {
valid: boolean;
identityKey?: string;
error?: string;
}
result = await function verifySignedRequest(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;
}>
verifySignedRequest(serverWallet: {
verifySignature: (args: any) => Promise<{
valid: boolean;
}>;
}
serverWallet, const proof: anyproof, { action: stringaction: 'create-note', body?: RequestBody | undefinedbody }, function consumeNonce(nonce: string, expiresAt: Date): booleanconsumeNonce)
var console: Consoleconsole.Console.log(message?: any, ...optionalParams: any[]): void (+1 overload)log(const result: {
valid: boolean;
identityKey?: string;
error?: string;
}
result)
// → { valid: true, identityKey: '02a1f3c9e8b7…' }
if (!const result: {
valid: boolean;
identityKey?: string;
error?: string;
}
result.valid: booleanvalid) { 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 }
res: Response<any, Record<string, any>>res.Response<any, Record<string, any>, number>.json: (body?: any) => Response<any, Record<string, any>>json({ by: string | undefinedby: const result: {
valid: boolean;
identityKey?: string;
error?: string;
}
result.identityKey?: string | undefinedidentityKey })
}Returns#
Promise<{ valid: boolean, identityKey?: string, error?: string }>. When valid is true, identityKey is the signer, proven. It's the only user ID you need. Then authorize it: a valid proof says who, not whether they're allowed.
Parameters#
serverWallet#
- Type:
{ verifySignature(args): Promise<{ valid: boolean }> }, in practice aProtoWallet
The server's own wallet: new ProtoWallet(PrivateKey.fromString(SERVER_PRIVATE_KEY)). Its identity is the proof's counterparty, so a proof made for another server fails here.
proof#
- Type:
AuthProof
Exactly what the client sent. Don't modify it.
opts.action#
- Type:
string
Must equal the action the client signed.
opts.body (optional)#
- Type:
RequestBody
The body the client sent. It's re-serialized to JSON for verification, so verify before any middleware rewrites it.
consumeNonce#
- Type:
(nonce: string, expiresAt: Date) => boolean | Promise<boolean>
Records a proof's nonce and returns true only the first time. Use the generated consumeNonce for one process, and a shared store for more.
Fails when#
The signature doesn't match, action or body differs, the proof is older than 2 minutes (30 s skew allowed), the nonce was used before, or the proof was made for a different server. All of these return { valid: false }; none of them throw.
Deep diveSource: verifySignedRequest.ts
// Framework-agnostic verification of a signed request. Works in Express, Next API
// routes, Fastify — it's a plain function. Pass your own single-use nonce store.
import { verifyAuthProof, type AuthProof, type RequestBody } from './auth.js'
export async function verifySignedRequest (
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 }> {
return await verifyAuthProof(serverWallet, proof, { action: opts.action, body: opts.body }, consumeNonce)
}loginRoute()#
An Express handler for POST /api/login. From wallet-login.
import { function loginRoute(serverWallet: {
verifySignature: (args: any) => Promise<{
valid: boolean;
}>;
}): (req: express.Request, res: express.Response) => Promise<void>
loginRoute } from './bsv/loginRoute.js'
const app: Expressapp.IRouter.post: IRouterMatcher
<"/api/login", ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>(path: "/api/login", ...handlers: RequestHandler<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>[]) => Express (+4 overloads)
post('/api/login', function loginRoute(serverWallet: {
verifySignature: (args: any) => Promise<{
valid: boolean;
}>;
}): (req: express.Request, res: express.Response) => Promise<void>
loginRoute(const serverWallet: ProtoWalletserverWallet))- Signature:
loginRoute(serverWallet): (req, res) => Promise<void> - Request body: the
AuthProofitself (not wrapped), signed withaction: 'login'. - Responds:
200 { identityKey }, or401 { "error": "invalid proof" }. - Sessions: none. Issue your own after a valid login: here's a tested JWT pattern.
Deep diveSource: loginRoute.ts
// Express login route. Mount: app.post('/api/login', loginRoute(serverWallet))
import type { Request, Response } from 'express'
import { verifyAuthProof } from './auth.js'
import { consumeNonce } from './nonceStore.js'
export function loginRoute (serverWallet: { verifySignature: (args: any) => Promise<{ valid: boolean }> }) {
return async (req: Request, res: Response): Promise<void> => {
const result = await verifyAuthProof(serverWallet, req.body, { action: 'login' }, consumeNonce)
if (!result.valid) { res.status(401).json({ error: 'invalid proof' }); return }
res.json({ identityKey: result.identityKey })
}
}verifyAuthProof()#
The lower-level check that verifySignedRequest and loginRoute both call, from the shared auth.ts.
- Signature:
verifyAuthProof(serverWallet, proof, { action: string, body?: RequestBody }, consumeNonce): Promise<{ valid, identityKey?, error? }> - Use it when you want a login-style proof (no body) on a custom route.
Deep diveSource: auth.ts (identical on client and server)
// Shared, framework-agnostic auth-proof helpers built on @bsv/auth (BRC-103).
// One primitive: sign a proof bound to { action, body? }, verify it on the server.
import { AuthProofClient, AuthProofServer, type AuthProof, type ProofSignerWallet, type RequestBody } from '@bsv/auth'
export type { AuthProof, RequestBody }
export async function createAuthProof (
wallet: ProofSignerWallet,
opts: { counterparty: string, action: string, body?: RequestBody }
): Promise<AuthProof> {
const client = new AuthProofClient()
return await client.createAuthProof({ wallet, counterparty: opts.counterparty, action: opts.action, body: opts.body })
}
export async 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 }> {
const server = new AuthProofServer()
return await server.verifyAuthProof({ wallet: serverWallet, proof, action: opts.action, body: opts.body, consumeNonce })
}consumeNonce()#
Single-use replay protection, in memory.
import { function consumeNonce(nonce: string, expiresAt: Date): booleanconsumeNonce } from './bsv/nonceStore.js'
const const first: booleanfirst = function consumeNonce(nonce: string, expiresAt: Date): booleanconsumeNonce('n0nce', new var Date: DateConstructor
new (value: number | string | Date) => Date (+3 overloads)
Date(var Date: DateConstructorDate.DateConstructor.now(): numbernow() + 60_000))
const const again: booleanagain = function consumeNonce(nonce: string, expiresAt: Date): booleanconsumeNonce('n0nce', new var Date: DateConstructor
new (value: number | string | Date) => Date (+3 overloads)
Date(var Date: DateConstructorDate.DateConstructor.now(): numbernow() + 60_000))
var console: Consoleconsole.Console.log(message?: any, ...optionalParams: any[]): void (+1 overload)log(const first: booleanfirst, const again: booleanagain)
// → true false- Signature:
consumeNonce(nonce: string, expiresAt: Date): boolean - Returns
falsefor a repeat, an already-expired proof, or when the store holds 10,000 live nonces. - Limits: one process only, emptied on restart. Replace it before you run more than one instance.
Deep diveSource: nonceStore.ts
// Bounded in-memory replay protection for the generated development server.
// Replace this with an atomic Redis/DB implementation before horizontally scaling.
const usedNonces = new Map<string, number>()
const MAX_NONCES = 10_000
function pruneExpired (now: number): void {
for (const [nonce, expiresAt] of usedNonces) {
if (expiresAt <= now) usedNonces.delete(nonce)
}
}
export function consumeNonce (nonce: string, expiresAt: Date): boolean {
const now = Date.now()
pruneExpired(now)
if (expiresAt.getTime() <= now || usedNonces.has(nonce) || usedNonces.size >= MAX_NONCES) return false
usedNonces.set(nonce, expiresAt.getTime())
return true
}Config#
server/src/bsv/config.ts reads and validates the environment once, at startup:
| Export | From | Default (dev) | Production |
|---|---|---|---|
SERVER_PRIVATE_KEY |
SERVER_PRIVATE_KEY |
random per start | required, canonical hex |
PORT |
PORT |
3000 |
optional |
CLIENT_ORIGIN |
CLIENT_ORIGIN |
http://localhost:5173 |
required, https:// origin |
BSV_NETWORK |
BSV_NETWORK |
'test' |
optional |
"Production" means NODE_ENV=production. Every failure throws at startup with the message listed in Errors.
Deep diveSource: config.ts
// Centralized server configuration, read from the environment.
import { PrivateKey } from '@bsv/sdk'
// Server wallet key. Set SERVER_PRIVATE_KEY for a stable identity; a random key is
// used as a dev fallback (the server's identity then changes on every restart).
const configuredServerKey = process.env.SERVER_PRIVATE_KEY
if (process.env.NODE_ENV === 'production' && configuredServerKey == null) {
throw new Error('SERVER_PRIVATE_KEY is required in production')
}
const parsedServerKey = configuredServerKey == null
? PrivateKey.fromRandom()
: PrivateKey.fromString(configuredServerKey)
if (configuredServerKey != null && parsedServerKey.toString() !== configuredServerKey) {
throw new Error('SERVER_PRIVATE_KEY must use its canonical encoding')
}
export const SERVER_PRIVATE_KEY = parsedServerKey.toString()
const portText = process.env.PORT ?? '3000'
if (!/^(?:[1-9]\d{0,4})$/.test(portText)) throw new Error('PORT must be an integer from 1 to 65535')
export const PORT = Number(portText)
if (PORT > 65535) throw new Error('PORT must be an integer from 1 to 65535')
// Browser origin allowed by CORS — your client's dev URL by default. CORS is not auth.
const configuredClientOrigin = process.env.CLIENT_ORIGIN
if (process.env.NODE_ENV === 'production' && configuredClientOrigin == null) {
throw new Error('CLIENT_ORIGIN is required in production')
}
const parsedClientOrigin = new URL(configuredClientOrigin ?? 'http://localhost:5173')
const localClient = parsedClientOrigin.protocol === 'http:' &&
(parsedClientOrigin.hostname === 'localhost' || parsedClientOrigin.hostname === '127.0.0.1' || parsedClientOrigin.hostname === '[::1]')
if ((parsedClientOrigin.protocol !== 'https:' && !localClient) || parsedClientOrigin.username !== '' ||
parsedClientOrigin.password !== '' || parsedClientOrigin.pathname !== '/' ||
parsedClientOrigin.search !== '' || parsedClientOrigin.hash !== '') {
throw new Error('CLIENT_ORIGIN must be credential-free HTTPS (or exact HTTP localhost development) origin')
}
export const CLIENT_ORIGIN = parsedClientOrigin.origin
const configuredNetwork = process.env.BSV_NETWORK ?? 'test'
if (configuredNetwork !== 'main' && configuredNetwork !== 'test' && configuredNetwork !== 'ttn') {
throw new Error('BSV_NETWORK must be main, test, or ttn')
}
export const BSV_NETWORK = configuredNetwork