From d0cec466f72a3901e675de388d987b7011d18419 Mon Sep 17 00:00:00 2001 From: Chris Duncan Date: Thu, 6 Aug 2026 08:19:39 -0700 Subject: [PATCH] Refactor RPC to require auth upfront instead of getting from env. --- sample.env.mjs | 4 ++ src/lib/errors.ts | 1 + src/lib/rpc/index.ts | 114 +++++++++++++++++++++++++++---------------- test/GLOBALS.mjs | 5 +- 4 files changed, 81 insertions(+), 43 deletions(-) diff --git a/sample.env.mjs b/sample.env.mjs index dde66e3..8ca329f 100644 --- a/sample.env.mjs +++ b/sample.env.mjs @@ -2,6 +2,10 @@ //! SPDX-License-Identifier: GPL-3.0-or-later // Save this file as `env.mjs` and replace the examples with real values + +/** + * @type {{LIBNEMO_RPC_URL: string | URL, LIBNEMO_RPC_AUTHORIZATION?: string | {header: string, value: string}}} + */ export const env = { // Specify a valid URL pointing to a live Nano node. diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 95399a9..5d7447d 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -1,4 +1,5 @@ //! SPDX-FileCopyrightText: 2025 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later +export class RpcError extends Error { } export class VaultError extends Error { } diff --git a/src/lib/rpc/index.ts b/src/lib/rpc/index.ts index afe1767..6bc3700 100644 --- a/src/lib/rpc/index.ts +++ b/src/lib/rpc/index.ts @@ -1,5 +1,6 @@ //! SPDX-FileCopyrightText: 2025 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later +import { RpcError } from '../errors' import { Schema } from './schema' export { Schema } @@ -12,10 +13,15 @@ export { Schema } * * The URL protocol must be HTTPS; any other protocol will be changed to * `https:` automatically. + * + * The optional authentication parameter accepts three kinds of argument: + * - JS object with a `header` property and a `value` property + * - JSON string that can be parsed into the above JS object format + * - Plain string used as the value for the standard "Authorization" HTTP header */ export class Rpc { #url: URL - #auth?: string | { header: string, value: string } + #auth?: { header: string, value: string } /** * @param {(string|URL)} url Nano network node @@ -29,10 +35,36 @@ export class Rpc { * @param {string} auth.value authorization token value */ constructor (url: string | URL, auth?: { header: string, value: string }) - constructor (url: string | URL, auth?: string | { header: string, value: string }) { - this.#url = new URL(url) - this.#url.protocol = 'https:' - this.#auth = auth + constructor (url: unknown, auth: unknown) { + if ((typeof url === 'string' || url instanceof URL) && URL.canParse(url)) { + this.#url = new URL(url) + this.#url.protocol = 'https:' + } else { + throw new RpcError('Invalid URL', { cause: url }) + } + + if (typeof auth === 'string') { + (auth as string) = auth.trim() + if (/^\{.*\}$/.test(auth)) { + auth = JSON.parse(auth) + } else { + auth = { header: 'Authorization', value: auth } + } + } + if (auth != null + && typeof auth === 'object' + && 'header' in auth + && typeof auth.header === 'string' + && 'value' in auth + && typeof auth.value === 'string' + ) { + this.#auth = { + header: auth.header, + value: auth.value + } + } else if (auth !== undefined) { + throw new RpcError('Unrecognized RPC authorization format') + } } /** @@ -42,39 +74,12 @@ export class Rpc { * @param {object} [data] - JSON to send to the node as defined by the action * @returns {Promise} JSON-formatted RPC results from the node */ - async post (action: string, data?: Record): Promise + async post (action: T, data?: Record): Promise async post (action: unknown, data: unknown): Promise { - const env = typeof process !== 'undefined' && 'env' in process ? process.env : null this.#validate(action) - const headers: Record = { - 'Content-Type': 'application/json' - } - const auth = this.#auth ?? env?.LIBNEMO_RPC_AUTHORIZATION - if (typeof auth === 'string') { - const token = /^\{.*\}$/.test(auth.trim()) ? JSON.parse(auth) : auth - headers['Authorization'] = token - } else if (typeof auth?.header === 'string' && typeof auth.value === 'string') { - headers[auth.header] = auth.value - } else { - console.warn('Unrecognized RPC authorization format') - } - if (data !== undefined && typeof data !== 'object') { - throw new TypeError('Invalid RPC post data') - } - const reqBody: Record = { ...data } - if ('action' in reqBody && (typeof reqBody.action !== 'string' || reqBody.action.toLowerCase() !== action.toLowerCase())) { - throw new RangeError(`RPC post data contains 'action' property and does not match 'action' parameter argument. Do not include 'action' in request data.`, { cause: JSON.stringify(reqBody) }) - } - reqBody.action = action.toLowerCase() - const aborter = new AbortController() - const req = new Request(this.#url, { - signal: aborter.signal, - method: 'POST', - headers, - body: JSON.stringify(reqBody) - }) - const kill = setTimeout(() => { + const req = this.#createRequest(action, data, aborter.signal) + const abort = setTimeout(() => { console.log('aborting RPC call') aborter.abort() }, 10000) @@ -85,20 +90,45 @@ export class Rpc { if (status !== 200) { throw new Error(`${status}${statusText}`) } - const resBody = await res.json() - const code = resBody.code ? `${resBody.code} ` : '' - const error = resBody.error ? `${resBody.error}\n` : '' - const message = resBody.message ?? '' + const body = await res.json() + const code = body.code ? `${body.code} ` : '' + const error = body.error ? `${body.error}\n` : '' + const message = body.message ?? '' if (error) { - throw new Error(`${code}${error}${message}`, { cause: JSON.stringify(resBody) }) + throw new Error(`${code}${error}${message}`, { cause: JSON.stringify(body) }) } - return resBody + return body } catch (err) { console.error(err) throw new Error(`RPC ${action} request failed`, { cause: err }) } finally { - clearTimeout(kill) + clearTimeout(abort) + } + } + + #createRequest (action: string, data: unknown, signal: AbortSignal): Request { + const headers: Record = { + 'Content-Type': 'application/json' } + if (this.#auth) { + headers[this.#auth.header] = this.#auth.value + } + + if (data !== undefined && typeof data !== 'object') { + throw new RpcError('Invalid RPC post data') + } + const body: Record = { ...data } + if ('action' in body && (typeof body.action !== 'string' || body.action.toLowerCase() !== action.toLowerCase())) { + throw new RangeError(`RPC post data contains 'action' property and does not match 'action' parameter argument. Do not include 'action' in request data.`, { cause: JSON.stringify(body) }) + } + body.action = action.toLowerCase() + + return new Request(this.#url, { + body: JSON.stringify(body), + headers, + method: 'POST', + signal, + }) } #validate (action: unknown): asserts action is string { diff --git a/test/GLOBALS.mjs b/test/GLOBALS.mjs index febde3c..17acc98 100644 --- a/test/GLOBALS.mjs +++ b/test/GLOBALS.mjs @@ -7,7 +7,10 @@ import { env } from '../env.mjs' export { env } export const isNode = globalThis !== globalThis.window -export const rpc = new Rpc(env?.LIBNEMO_RPC_URL ?? '', env.LIBNEMO_RPC_AUTHORIZATION) + +const url = env.LIBNEMO_RPC_URL +const auth = env.LIBNEMO_RPC_AUTHORIZATION +export const rpc = new Rpc(url, auth) if (globalThis.sessionStorage == null) { let _sessionStorage = {} -- 2.52.0