From ed2c4deb76522d7df4f1bb971d59350fdf9c8aeb Mon Sep 17 00:00:00 2001 From: dragosp1011 Date: Tue, 6 Oct 2026 13:16:08 +0300 Subject: [PATCH 1/2] feat(rhyza): add admin and idp clients --- packages/wallet/backend/.env.example | 4 + packages/wallet/backend/src/config/env.ts | 2 + packages/wallet/backend/src/config/rhyza.ts | 26 +++ .../wallet/backend/src/createContainer.ts | 7 + .../wallet/backend/src/rhyza/admin-client.ts | 19 ++ .../wallet/backend/src/rhyza/http-client.ts | 147 ++++++++++++ .../wallet/backend/src/rhyza/idp-client.ts | 40 ++++ packages/wallet/backend/src/rhyza/types.ts | 47 ++++ .../backend/tests/rhyza/admin-client.test.ts | 56 +++++ .../wallet/backend/tests/rhyza/helpers.ts | 67 ++++++ .../backend/tests/rhyza/http-client.test.ts | 215 ++++++++++++++++++ .../backend/tests/rhyza/idp-client.test.ts | 103 +++++++++ 12 files changed, 733 insertions(+) create mode 100644 packages/wallet/backend/src/config/rhyza.ts create mode 100644 packages/wallet/backend/src/rhyza/admin-client.ts create mode 100644 packages/wallet/backend/src/rhyza/http-client.ts create mode 100644 packages/wallet/backend/src/rhyza/idp-client.ts create mode 100644 packages/wallet/backend/src/rhyza/types.ts create mode 100644 packages/wallet/backend/tests/rhyza/admin-client.test.ts create mode 100644 packages/wallet/backend/tests/rhyza/helpers.ts create mode 100644 packages/wallet/backend/tests/rhyza/http-client.test.ts create mode 100644 packages/wallet/backend/tests/rhyza/idp-client.test.ts diff --git a/packages/wallet/backend/.env.example b/packages/wallet/backend/.env.example index 457b801ab..01a66a057 100644 --- a/packages/wallet/backend/.env.example +++ b/packages/wallet/backend/.env.example @@ -56,6 +56,10 @@ # OPEN_PAYMENTS_HOST=https://rafiki-backend.testnet.test # RAFIKI_MONEY_FRONTEND_HOST=testnet.test +## Rhyza (Rafiki v2). The IdP client sends AUTH_IDENTITY_SERVER_SECRET as x-idp-secret. +# RHYZA_ADMIN_API_URL=http://localhost:3021 +# RHYZA_IDP_API_URL=http://localhost:3024 + ########## EMAIL / CARD / STRIPE / RATE LIMIT ################################################### ## Optional integration settings for local feature testing. diff --git a/packages/wallet/backend/src/config/env.ts b/packages/wallet/backend/src/config/env.ts index 086958246..ca59add45 100644 --- a/packages/wallet/backend/src/config/env.ts +++ b/packages/wallet/backend/src/config/env.ts @@ -68,6 +68,8 @@ export const envSchema = z .default('http://rafiki-auth:3008/graphql'), AUTH_DOMAIN: z.string().url().default('https://auth.testnet.test'), AUTH_IDENTITY_SERVER_SECRET: z.string().default('replace-me'), + RHYZA_ADMIN_API_URL: z.string().url().default('http://localhost:3021'), + RHYZA_IDP_API_URL: z.string().url().default('http://localhost:3024'), RAFIKI_WEBHOOK_SIGNATURE_SECRET: z.string().default('replace-me'), ADMIN_SIGNATURE_VERSION: z.string().default('1'), ADMIN_API_SECRET: z.string().default('replace-me'), diff --git a/packages/wallet/backend/src/config/rhyza.ts b/packages/wallet/backend/src/config/rhyza.ts new file mode 100644 index 000000000..22d73abdf --- /dev/null +++ b/packages/wallet/backend/src/config/rhyza.ts @@ -0,0 +1,26 @@ +import { Env } from '@/config/env' +import { Logger } from 'winston' +import { HttpClient } from '@/rhyza/http-client' +import { RhyzaAdminClient } from '@/rhyza/admin-client' +import { RhyzaIdpClient } from '@/rhyza/idp-client' + +export function createRhyzaAdminClient(env: Env, logger: Logger) { + return new RhyzaAdminClient( + new HttpClient( + { baseUrl: env.RHYZA_ADMIN_API_URL }, + logger.child({ service: RhyzaAdminClient.name }) + ) + ) +} + +export function createRhyzaIdpClient(env: Env, logger: Logger) { + return new RhyzaIdpClient( + new HttpClient( + { + baseUrl: env.RHYZA_IDP_API_URL, + headers: { 'x-idp-secret': env.AUTH_IDENTITY_SERVER_SECRET } + }, + logger.child({ service: RhyzaIdpClient.name }) + ) + ) +} diff --git a/packages/wallet/backend/src/createContainer.ts b/packages/wallet/backend/src/createContainer.ts index cdfc55e6b..3f10309a6 100644 --- a/packages/wallet/backend/src/createContainer.ts +++ b/packages/wallet/backend/src/createContainer.ts @@ -18,6 +18,8 @@ import { RafikiAuthService } from '@/rafiki/auth/service' import { RafikiController } from '@/rafiki/controller' import { RafikiClient } from '@/rafiki/rafiki-client' import { RafikiService } from '@/rafiki/service' +import { RhyzaAdminClient } from '@/rhyza/admin-client' +import { RhyzaIdpClient } from '@/rhyza/idp-client' import { SessionService } from '@/session/service' import { TransactionController } from '@/transaction/controller' import { TransactionService } from '@/transaction/service' @@ -41,6 +43,7 @@ import { createAuthGraphQLClient, createBackendGraphQLClient } from '@/config/rafiki' +import { createRhyzaAdminClient, createRhyzaIdpClient } from '@/config/rhyza' import { WalletAddressKeyController } from '@/walletAddressKeys/controller' import { WalletAddressKeyService } from '@/walletAddressKeys/service' import { generateKnex } from '@/config/knex' @@ -81,6 +84,8 @@ export interface Cradle { authGraphQLClient: GraphQLClient rafikiClient: RafikiClient rafikiAuthService: RafikiAuthService + rhyzaAdminClient: RhyzaAdminClient + rhyzaIdpClient: RhyzaIdpClient accountService: AccountService ratesService: RatesService redisClient: RedisClient @@ -143,6 +148,8 @@ export async function createContainer( authGraphQLClient: asFunction(createAuthGraphQLClient).singleton(), rafikiClient: asClass(RafikiClient).singleton(), rafikiAuthService: asClass(RafikiAuthService).singleton(), + rhyzaAdminClient: asFunction(createRhyzaAdminClient).singleton(), + rhyzaIdpClient: asFunction(createRhyzaIdpClient).singleton(), accountService: asClass(AccountService).singleton(), ratesService: asClass(RatesService).singleton(), redisClient: asFunction(createRedis).singleton(), diff --git a/packages/wallet/backend/src/rhyza/admin-client.ts b/packages/wallet/backend/src/rhyza/admin-client.ts new file mode 100644 index 000000000..07d2d859d --- /dev/null +++ b/packages/wallet/backend/src/rhyza/admin-client.ts @@ -0,0 +1,19 @@ +import { HttpClient } from '@/rhyza/http-client' +import { Asset } from '@/rhyza/types' + +interface AssetCreateResponse { + code: string +} + +export class RhyzaAdminClient { + constructor(private http: HttpClient) {} + + async createAsset(code: string, scale: number): Promise { + const response = await this.http.post('/assets', { + code, + scale + }) + // The response echoes only the code. + return { code: response.code, scale } + } +} diff --git a/packages/wallet/backend/src/rhyza/http-client.ts b/packages/wallet/backend/src/rhyza/http-client.ts new file mode 100644 index 000000000..341507160 --- /dev/null +++ b/packages/wallet/backend/src/rhyza/http-client.ts @@ -0,0 +1,147 @@ +import axios, { AxiosError, AxiosInstance } from 'axios' +import { Logger } from 'winston' +import { + BadRequest, + BaseError, + Conflict, + InternalServerError, + NotFound +} from '@shared/backend' + +const DEFAULT_TIMEOUT_MS = 10_000 + +type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + +export interface HttpClientOptions { + baseUrl: string + headers?: Record + timeoutMs?: number +} + +export interface RequestOptions { + body?: unknown + headers?: Record +} + +// Users still see "Internal Server Error"; the upstream status and body stay on the error for logs and callers. +export class RhyzaServerError extends InternalServerError { + constructor( + public readonly status: number, + public readonly body: unknown + ) { + super() + Object.setPrototypeOf(this, RhyzaServerError.prototype) + } +} + +export class HttpClient { + private readonly axios: AxiosInstance + private readonly timeoutMs: number + + constructor( + options: HttpClientOptions, + private logger: Logger + ) { + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + this.axios = axios.create({ + baseURL: options.baseUrl.replace(/\/+$/, ''), + headers: options.headers, + timeout: this.timeoutMs, + // Status mapping is ours; parse JSON leniently so a plain-text error body survives. + responseType: 'text', + transformResponse: (data: string) => (data ? parseBody(data) : undefined) + }) + } + + get(path: string, options?: RequestOptions): Promise { + return this.request('GET', path, options) + } + + post(path: string, body?: unknown, options?: RequestOptions): Promise { + return this.request('POST', path, { ...options, body }) + } + + async request( + method: Method, + path: string, + options: RequestOptions = {} + ): Promise { + const hasBody = options.body !== undefined + try { + const response = await this.axios.request({ + method, + url: path, + data: hasBody ? JSON.stringify(options.body) : undefined, + // Fastify rejects an empty body sent with a content type; axios would otherwise add one to a bodyless POST. + headers: { + 'Content-Type': hasBody ? 'application/json' : false, + ...options.headers + } + }) + this.logger.debug(`${method} ${path} ${response.status}`) + return response.data + } catch (e) { + if (!(e instanceof AxiosError)) throw e + if (!e.response) { + if ( + e.code === AxiosError.ECONNABORTED || + e.code === AxiosError.ETIMEDOUT + ) { + throw new Error( + `Rhyza ${method} ${path} timed out after ${this.timeoutMs}ms`, + { cause: e } + ) + } + // The shared error handler only logs an AxiosError's response, which is absent here. + throw new Error( + `Rhyza ${method} ${path} failed: ${e.code ?? e.message}`, + { cause: e } + ) + } + + const { status, data: body } = e.response + this.logger.debug(`${method} ${path} ${status}`) + const error = toError(status, body) + if (error instanceof RhyzaServerError) { + this.logger.error(`${method} ${path} ${status}`, { body }) + } else { + this.logger.warn(`${method} ${path} ${status}`, { body }) + } + throw error + } + } +} + +function parseBody(text: string): unknown { + try { + return JSON.parse(text) + } catch { + return text + } +} + +// Admin API errors use `error`; Fastify defaults put the detail in `message`. +function messageFrom(body: unknown): string | undefined { + if (typeof body === 'string') return body || undefined + if (body && typeof body === 'object') { + const { message, error } = body as Record + if (typeof message === 'string') return message + if (typeof error === 'string') return error + } + return undefined +} + +// A 401/403 means the wallet's own credentials are wrong, so it falls through to RhyzaServerError. +function toError(status: number, body: unknown): BaseError { + const message = messageFrom(body) + switch (status) { + case 400: + return new BadRequest(message ?? 'Bad Request') + case 404: + return new NotFound(message) + case 409: + return new Conflict(message ?? 'Conflict') + default: + return new RhyzaServerError(status, body) + } +} diff --git a/packages/wallet/backend/src/rhyza/idp-client.ts b/packages/wallet/backend/src/rhyza/idp-client.ts new file mode 100644 index 000000000..59d80ec24 --- /dev/null +++ b/packages/wallet/backend/src/rhyza/idp-client.ts @@ -0,0 +1,40 @@ +import { NotFound } from '@shared/backend' +import { HttpClient, RhyzaServerError } from '@/rhyza/http-client' +import { Grant, GrantAccess, GrantState, GrantSubjectId } from '@/rhyza/types' + +interface GrantResponse { + id: string + access: GrantAccess[] + subject?: { sub_ids: GrantSubjectId[] } + state: GrantState +} + +const GRPC_NOT_FOUND = '5' + +export class RhyzaIdpClient { + constructor(private http: HttpClient) {} + + async getGrant(id: string): Promise { + try { + const response = await this.http.get( + `/grant/${encodeURIComponent(id)}` + ) + return { + id: response.id, + state: response.state, + access: response.access, + subjectIds: response.subject?.sub_ids ?? [] + } + } catch (e) { + // The IdP has no error handler, so a missing grant comes back as a 500 carrying the gRPC code. + if (isGrpcNotFound(e)) throw new NotFound(`Grant ${id} not found`) + throw e + } + } +} + +function isGrpcNotFound(e: unknown): boolean { + if (!(e instanceof RhyzaServerError)) return false + const body = e.body as { code?: unknown } | undefined + return body?.code === GRPC_NOT_FOUND +} diff --git a/packages/wallet/backend/src/rhyza/types.ts b/packages/wallet/backend/src/rhyza/types.ts new file mode 100644 index 000000000..3c65c7478 --- /dev/null +++ b/packages/wallet/backend/src/rhyza/types.ts @@ -0,0 +1,47 @@ +// Wallet-owned shapes for Rhyza data. Not generated from any Rhyza or GraphQL artifact. + +// Rhyza identifies assets by code alone; scale is a display property. +export interface Asset { + code: string + scale: number +} + +export type GrantState = + | 'PROCESSING' + | 'PENDING' + | 'APPROVED' + | 'ISSUED' + | 'REJECTED' + | 'REVOKED' + +export interface GrantAmount { + value: string + assetCode: string + assetScale: number +} + +export interface GrantAccessLimits { + receiver?: string + interval?: string + debitAmount?: GrantAmount + receiveAmount?: GrantAmount +} + +export interface GrantAccess { + type: string + actions: string[] + identifier?: string + limits?: GrantAccessLimits +} + +export interface GrantSubjectId { + format: string + id: string +} + +export interface Grant { + id: string + state: GrantState + access: GrantAccess[] + subjectIds: GrantSubjectId[] +} diff --git a/packages/wallet/backend/tests/rhyza/admin-client.test.ts b/packages/wallet/backend/tests/rhyza/admin-client.test.ts new file mode 100644 index 000000000..25096bba4 --- /dev/null +++ b/packages/wallet/backend/tests/rhyza/admin-client.test.ts @@ -0,0 +1,56 @@ +import { Conflict } from '@shared/backend' +import { HttpClient } from '@/rhyza/http-client' +import { RhyzaAdminClient } from '@/rhyza/admin-client' +import { lastRequest, mockAdapter, mockLogger, respond } from './helpers' + +describe('RhyzaAdminClient', () => { + let adapter: ReturnType['adapter'] + let restore: () => void + let client: RhyzaAdminClient + + beforeEach(() => { + ;({ adapter, restore } = mockAdapter()) + client = new RhyzaAdminClient( + new HttpClient({ baseUrl: 'http://admin.test' }, mockLogger()) + ) + }) + + afterEach(() => { + restore() + }) + + describe('createAsset', () => { + it('POSTs code and scale to /assets', async () => { + adapter.mockImplementationOnce(respond(201, { code: 'USD' })) + + await client.createAsset('USD', 2) + + const request = lastRequest(adapter) + expect(request.method).toBe('POST') + expect(request.url).toBe('http://admin.test/assets') + expect(request.body).toEqual({ code: 'USD', scale: 2 }) + expect(request.headers).not.toHaveProperty('x-idp-secret') + }) + + it('returns the created Asset on 201', async () => { + adapter.mockImplementationOnce(respond(201, { code: 'USD' })) + + await expect(client.createAsset('USD', 2)).resolves.toEqual({ + code: 'USD', + scale: 2 + }) + }) + + it('raises Conflict on 409', async () => { + adapter.mockImplementationOnce( + respond(409, { + error: '13 INTERNAL: Asset with code: USD already exists' + }) + ) + + await expect(client.createAsset('USD', 2)).rejects.toBeInstanceOf( + Conflict + ) + }) + }) +}) diff --git a/packages/wallet/backend/tests/rhyza/helpers.ts b/packages/wallet/backend/tests/rhyza/helpers.ts new file mode 100644 index 000000000..559fc69ca --- /dev/null +++ b/packages/wallet/backend/tests/rhyza/helpers.ts @@ -0,0 +1,67 @@ +import axios, { + AxiosAdapter, + AxiosError, + AxiosResponse, + InternalAxiosRequestConfig +} from 'axios' +import { Logger } from 'winston' + +export const mockLogger = () => + ({ + debug: jest.fn(), + warn: jest.fn(), + error: jest.fn() + }) as unknown as Logger + +// Stands in for axios's network adapter; must be installed before the HttpClient is built. +export const mockAdapter = () => { + const original = axios.defaults.adapter + const adapter = jest.fn< + ReturnType, + [InternalAxiosRequestConfig] + >() + axios.defaults.adapter = adapter + return { + adapter, + restore: () => { + axios.defaults.adapter = original + } + } +} + +// Mirrors axios's own settle(): non-2xx rejects with an AxiosError carrying the response. +export const respond = + (status: number, body?: unknown) => + async (config: InternalAxiosRequestConfig): Promise => { + const response: AxiosResponse = { + data: + body === undefined + ? '' + : typeof body === 'string' + ? body + : JSON.stringify(body), + status, + statusText: '', + headers: {}, + config + } + if (config.validateStatus?.(status) ?? true) return response + throw new AxiosError( + `Request failed with status code ${status}`, + AxiosError.ERR_BAD_RESPONSE, + config, + null, + response + ) + } + +export const lastRequest = (adapter: jest.Mock) => { + const config = adapter.mock.calls.at(-1)?.[0] as InternalAxiosRequestConfig + return { + url: `${config.baseURL}${config.url}`, + method: config.method?.toUpperCase(), + headers: config.headers.toJSON() as Record, + timeout: config.timeout, + body: config.data === undefined ? undefined : JSON.parse(config.data) + } +} diff --git a/packages/wallet/backend/tests/rhyza/http-client.test.ts b/packages/wallet/backend/tests/rhyza/http-client.test.ts new file mode 100644 index 000000000..04a2494d2 --- /dev/null +++ b/packages/wallet/backend/tests/rhyza/http-client.test.ts @@ -0,0 +1,215 @@ +import { AxiosError, InternalAxiosRequestConfig } from 'axios' +import { + BadRequest, + Conflict, + InternalServerError, + NotFound +} from '@shared/backend' +import { HttpClient, RhyzaServerError } from '@/rhyza/http-client' +import { lastRequest, mockAdapter, mockLogger, respond } from './helpers' + +describe('Rhyza HttpClient', () => { + let adapter: ReturnType['adapter'] + let restore: () => void + let logger: ReturnType + let client: HttpClient + + beforeEach(() => { + ;({ adapter, restore } = mockAdapter()) + logger = mockLogger() + client = new HttpClient( + { baseUrl: 'http://rhyza.test/', headers: { 'x-default': 'a' } }, + logger + ) + }) + + afterEach(() => { + restore() + }) + + it('sends JSON to the joined URL and parses the JSON response', async () => { + adapter.mockImplementationOnce(respond(201, { ok: true })) + + const result = await client.post('/things', { a: 1 }) + + expect(result).toEqual({ ok: true }) + const request = lastRequest(adapter) + expect(request.url).toBe('http://rhyza.test/things') + expect(request.method).toBe('POST') + expect(request.body).toEqual({ a: 1 }) + expect(request.headers['Content-Type']).toBe('application/json') + }) + + it('logs method, path and status at debug', async () => { + adapter.mockImplementationOnce(respond(200, {})) + + await client.get('/things/1') + + expect(logger.debug).toHaveBeenCalledWith('GET /things/1 200') + }) + + it('merges per-request headers over the defaults', async () => { + adapter.mockImplementationOnce(respond(200, {})) + + await client.get('/things', { + headers: { 'x-default': 'b', 'x-extra': 'c' } + }) + + expect(lastRequest(adapter).headers).toMatchObject({ + 'x-default': 'b', + 'x-extra': 'c' + }) + }) + + it('sends no body on GET', async () => { + adapter.mockImplementationOnce(respond(200, {})) + + await client.get('/things') + + expect(lastRequest(adapter).body).toBeUndefined() + }) + + it('omits Content-Type on a bodyless POST', async () => { + adapter.mockImplementationOnce(respond(202)) + + await client.post('/things/1/accept') + + const request = lastRequest(adapter) + expect(request.body).toBeUndefined() + expect(request.headers).not.toHaveProperty('Content-Type') + }) + + it.each([202, 204])('returns undefined for an empty %s', async (status) => { + adapter.mockImplementationOnce(respond(status)) + + await expect(client.post('/things/1/accept')).resolves.toBeUndefined() + }) + + it.each([ + [400, BadRequest], + [401, RhyzaServerError], + [403, RhyzaServerError], + [404, NotFound], + [409, Conflict], + [500, InternalServerError], + [503, InternalServerError] + ])('maps %s to %p', async (status, ErrorClass) => { + adapter.mockImplementationOnce(respond(status)) + + await expect(client.get('/things')).rejects.toBeInstanceOf(ErrorClass) + }) + + it('puts the Admin API `error` field in the message', async () => { + adapter.mockImplementationOnce( + respond(409, { error: 'Asset with code: USD already exists' }) + ) + + await expect(client.get('/things')).rejects.toThrow( + 'Asset with code: USD already exists' + ) + }) + + it('puts the Fastify `message` field in the message', async () => { + adapter.mockImplementationOnce( + respond(404, { error: 'Not Found', message: 'Route not found' }) + ) + + await expect(client.get('/things')).rejects.toThrow('Route not found') + }) + + it('puts a plain-text body in the message', async () => { + adapter.mockImplementationOnce(respond(400, 'nope')) + + await expect(client.get('/things')).rejects.toThrow('nope') + }) + + it.each([400, 404, 409])( + 'logs a mapped %s at warn, not error', + async (status) => { + const body = { error: 'upstream detail' } + adapter.mockImplementationOnce(respond(status, body)) + + await client.get('/things').catch(() => undefined) + + expect(logger.warn).toHaveBeenCalledWith(`GET /things ${status}`, { + body + }) + expect(logger.error).not.toHaveBeenCalled() + } + ) + + it('keeps 401 generic for the user and logs it', async () => { + const body = { message: 'bad secret' } + adapter.mockImplementationOnce(respond(401, body)) + + const error = await client + .get('/things') + .catch((e: RhyzaServerError) => e) + + expect(error.message).toBe('Internal Server Error') + expect(error.statusCode).toBe(500) + expect(logger.error).toHaveBeenCalledWith('GET /things 401', { body }) + }) + + it('keeps 5xx messages generic but carries and logs the upstream body', async () => { + const body = { code: '13', message: 'database exploded' } + adapter.mockImplementationOnce(respond(500, body)) + + const error = await client + .get('/things') + .catch((e: RhyzaServerError) => e) + + expect(error).toBeInstanceOf(RhyzaServerError) + expect(error.message).toBe('Internal Server Error') + expect(error.status).toBe(500) + expect(error.body).toEqual(body) + expect(logger.error).toHaveBeenCalledWith('GET /things 500', { body }) + }) + + it.each([AxiosError.ECONNABORTED, AxiosError.ETIMEDOUT])( + 'passes the timeout to axios and reports %s as a timeout', + async (code) => { + adapter.mockImplementationOnce(async (config) => { + throw new AxiosError('timeout', code, config) + }) + const fast = new HttpClient( + { baseUrl: 'http://rhyza.test', timeoutMs: 20 }, + logger + ) + + await expect(fast.get('/slow')).rejects.toThrow( + 'Rhyza GET /slow timed out after 20ms' + ) + expect(lastRequest(adapter).timeout).toBe(20) + } + ) + + it('wraps network errors with method, path and code', async () => { + let networkError: AxiosError | undefined + adapter.mockImplementationOnce( + async (config: InternalAxiosRequestConfig) => { + networkError = new AxiosError( + 'connect ECONNREFUSED', + 'ECONNREFUSED', + config + ) + throw networkError + } + ) + + const error = await client.get('/things').catch((e: Error) => e) + + expect(error).not.toBeInstanceOf(AxiosError) + expect(error.message).toBe('Rhyza GET /things failed: ECONNREFUSED') + expect(error.cause).toBe(networkError) + }) + + it('rethrows non-axios errors unchanged', async () => { + const boom = new TypeError('boom') + adapter.mockImplementationOnce(async () => { + throw boom + }) + + await expect(client.get('/things')).rejects.toBe(boom) + }) +}) diff --git a/packages/wallet/backend/tests/rhyza/idp-client.test.ts b/packages/wallet/backend/tests/rhyza/idp-client.test.ts new file mode 100644 index 000000000..ec16e4923 --- /dev/null +++ b/packages/wallet/backend/tests/rhyza/idp-client.test.ts @@ -0,0 +1,103 @@ +import { NotFound } from '@shared/backend' +import { HttpClient, RhyzaServerError } from '@/rhyza/http-client' +import { RhyzaIdpClient } from '@/rhyza/idp-client' +import { lastRequest, mockAdapter, mockLogger, respond } from './helpers' + +describe('RhyzaIdpClient', () => { + let adapter: ReturnType['adapter'] + let restore: () => void + let client: RhyzaIdpClient + + const grantResponse = { + id: 'grant-1', + state: 'PENDING', + access: [ + { + type: 'outgoing-payment', + actions: ['create', 'read'], + identifier: 'https://ilp.test/alice', + limits: { + debitAmount: { value: '1000', assetCode: 'USD', assetScale: 2 } + } + } + ], + subject: { sub_ids: [{ format: 'uri', id: 'https://ilp.test/alice' }] } + } + + beforeEach(() => { + ;({ adapter, restore } = mockAdapter()) + client = new RhyzaIdpClient( + new HttpClient( + { baseUrl: 'http://idp.test', headers: { 'x-idp-secret': 'shh' } }, + mockLogger() + ) + ) + }) + + afterEach(() => { + restore() + }) + + describe('getGrant', () => { + it('GETs /grant/{id} with the IdP secret', async () => { + adapter.mockImplementationOnce(respond(200, grantResponse)) + + await client.getGrant('grant/1') + + const request = lastRequest(adapter) + expect(request.method).toBe('GET') + expect(request.url).toBe('http://idp.test/grant/grant%2F1') + expect(request.headers['x-idp-secret']).toBe('shh') + }) + + it('maps 200 to a Grant', async () => { + adapter.mockImplementationOnce(respond(200, grantResponse)) + + await expect(client.getGrant('grant-1')).resolves.toEqual({ + id: 'grant-1', + state: 'PENDING', + access: grantResponse.access, + subjectIds: [{ format: 'uri', id: 'https://ilp.test/alice' }] + }) + }) + + it('defaults subjectIds to empty when the grant has no subject', async () => { + adapter.mockImplementationOnce( + respond(200, { ...grantResponse, subject: undefined }) + ) + + const grant = await client.getGrant('grant-1') + + expect(grant.subjectIds).toEqual([]) + }) + + it('raises NotFound on 404', async () => { + adapter.mockImplementationOnce(respond(404, { message: 'Not Found' })) + + await expect(client.getGrant('missing')).rejects.toBeInstanceOf(NotFound) + }) + + it('raises NotFound when the IdP leaks a gRPC NOT_FOUND as 500', async () => { + adapter.mockImplementationOnce( + respond(500, { + statusCode: 500, + code: '5', + error: 'Internal Server Error', + message: "5 NOT_FOUND: Grant 'missing' not found" + }) + ) + + await expect(client.getGrant('missing')).rejects.toBeInstanceOf(NotFound) + }) + + it('keeps other 500s as server errors', async () => { + adapter.mockImplementationOnce( + respond(500, { statusCode: 500, code: '14' }) + ) + + await expect(client.getGrant('grant-1')).rejects.toBeInstanceOf( + RhyzaServerError + ) + }) + }) +}) From 3a062c8c59f0cc84d17bdc4c0ef5c4b3543d08c4 Mon Sep 17 00:00:00 2001 From: dragosp1011 Date: Tue, 6 Oct 2026 13:33:59 +0300 Subject: [PATCH 2/2] feat: move wallet address creation to rhyza client --- .../backend/src/rafiki/rafiki-client.ts | 33 ---------- .../wallet/backend/src/rhyza/admin-client.ts | 26 +++++++- .../wallet/backend/src/rhyza/http-client.ts | 12 ++-- .../wallet/backend/src/rhyza/idp-client.ts | 2 +- packages/wallet/backend/src/rhyza/types.ts | 9 ++- .../backend/src/walletAddress/service.ts | 29 ++++++--- .../backend/tests/rhyza/admin-client.test.ts | 46 ++++++++++++++ .../wallet/backend/tests/rhyza/helpers.ts | 4 +- .../tests/walletAddress/service.test.ts | 63 +++++++++++++++++-- 9 files changed, 163 insertions(+), 61 deletions(-) diff --git a/packages/wallet/backend/src/rafiki/rafiki-client.ts b/packages/wallet/backend/src/rafiki/rafiki-client.ts index 99b5ce5a8..277c8ae45 100644 --- a/packages/wallet/backend/src/rafiki/rafiki-client.ts +++ b/packages/wallet/backend/src/rafiki/rafiki-client.ts @@ -269,39 +269,6 @@ export class RafikiClient implements IRafikiClient { return response.payment as OutgoingPayment } - public async createRhyzaWalletAddress( - address: string, - assetCode: string, - publicName: string, - isActive: boolean - ) { - const response = await fetch( - `${process.env.RHYZA_ADMIN_API_URL}/wallet-addresses`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - address, - assetCode, - publicName, - isActive - }) - } - ) - - if (response.status === 201) { - return (await response.json()) as { id: string; address: string } - } - - if (response.status === 409) { - throw new Error() - } - - throw new Error(`Failed to create wallet address: ${response.statusText}`) - } - public async createRafikiWalletAddress( publicName: string, assetId: string, diff --git a/packages/wallet/backend/src/rhyza/admin-client.ts b/packages/wallet/backend/src/rhyza/admin-client.ts index 07d2d859d..565b88157 100644 --- a/packages/wallet/backend/src/rhyza/admin-client.ts +++ b/packages/wallet/backend/src/rhyza/admin-client.ts @@ -1,10 +1,22 @@ import { HttpClient } from '@/rhyza/http-client' -import { Asset } from '@/rhyza/types' +import { Asset, WalletAddress } from '@/rhyza/types' interface AssetCreateResponse { code: string } +export interface CreateWalletAddressArgs { + address: string + assetCode: string + publicName: string + isActive?: boolean +} + +interface WalletAddressCreateResponse { + id: string + address: string +} + export class RhyzaAdminClient { constructor(private http: HttpClient) {} @@ -13,7 +25,17 @@ export class RhyzaAdminClient { code, scale }) - // The response echoes only the code. + // The response carries only the code. return { code: response.code, scale } } + + async createWalletAddress( + args: CreateWalletAddressArgs + ): Promise { + const response = await this.http.post( + '/wallet-addresses', + args + ) + return { id: response.id, address: response.address } + } } diff --git a/packages/wallet/backend/src/rhyza/http-client.ts b/packages/wallet/backend/src/rhyza/http-client.ts index 341507160..267e785bd 100644 --- a/packages/wallet/backend/src/rhyza/http-client.ts +++ b/packages/wallet/backend/src/rhyza/http-client.ts @@ -23,7 +23,7 @@ export interface RequestOptions { headers?: Record } -// Users still see "Internal Server Error"; the upstream status and body stay on the error for logs and callers. +// Generic message for users; upstream status and body are kept for logs. export class RhyzaServerError extends InternalServerError { constructor( public readonly status: number, @@ -47,7 +47,7 @@ export class HttpClient { baseURL: options.baseUrl.replace(/\/+$/, ''), headers: options.headers, timeout: this.timeoutMs, - // Status mapping is ours; parse JSON leniently so a plain-text error body survives. + // Lenient parse so plain-text error bodies survive. responseType: 'text', transformResponse: (data: string) => (data ? parseBody(data) : undefined) }) @@ -72,7 +72,7 @@ export class HttpClient { method, url: path, data: hasBody ? JSON.stringify(options.body) : undefined, - // Fastify rejects an empty body sent with a content type; axios would otherwise add one to a bodyless POST. + // Fastify rejects an empty body with a content type. headers: { 'Content-Type': hasBody ? 'application/json' : false, ...options.headers @@ -92,7 +92,7 @@ export class HttpClient { { cause: e } ) } - // The shared error handler only logs an AxiosError's response, which is absent here. + // The shared error handler would log a blank line for this AxiosError. throw new Error( `Rhyza ${method} ${path} failed: ${e.code ?? e.message}`, { cause: e } @@ -120,7 +120,7 @@ function parseBody(text: string): unknown { } } -// Admin API errors use `error`; Fastify defaults put the detail in `message`. +// Admin API uses `error`; Fastify uses `message`. function messageFrom(body: unknown): string | undefined { if (typeof body === 'string') return body || undefined if (body && typeof body === 'object') { @@ -131,7 +131,7 @@ function messageFrom(body: unknown): string | undefined { return undefined } -// A 401/403 means the wallet's own credentials are wrong, so it falls through to RhyzaServerError. +// 401/403 means our credentials are wrong, not the user's. function toError(status: number, body: unknown): BaseError { const message = messageFrom(body) switch (status) { diff --git a/packages/wallet/backend/src/rhyza/idp-client.ts b/packages/wallet/backend/src/rhyza/idp-client.ts index 59d80ec24..f98ad2e8d 100644 --- a/packages/wallet/backend/src/rhyza/idp-client.ts +++ b/packages/wallet/backend/src/rhyza/idp-client.ts @@ -26,7 +26,7 @@ export class RhyzaIdpClient { subjectIds: response.subject?.sub_ids ?? [] } } catch (e) { - // The IdP has no error handler, so a missing grant comes back as a 500 carrying the gRPC code. + // The IdP returns a missing grant as a 500 with gRPC code 5. if (isGrpcNotFound(e)) throw new NotFound(`Grant ${id} not found`) throw e } diff --git a/packages/wallet/backend/src/rhyza/types.ts b/packages/wallet/backend/src/rhyza/types.ts index 3c65c7478..4623e6f29 100644 --- a/packages/wallet/backend/src/rhyza/types.ts +++ b/packages/wallet/backend/src/rhyza/types.ts @@ -1,11 +1,14 @@ -// Wallet-owned shapes for Rhyza data. Not generated from any Rhyza or GraphQL artifact. - -// Rhyza identifies assets by code alone; scale is a display property. +// Rhyza has no asset ids; code is the key. export interface Asset { code: string scale: number } +export interface WalletAddress { + id: string + address: string +} + export type GrantState = | 'PROCESSING' | 'PENDING' diff --git a/packages/wallet/backend/src/walletAddress/service.ts b/packages/wallet/backend/src/walletAddress/service.ts index 795db52bf..34812b0ef 100644 --- a/packages/wallet/backend/src/walletAddress/service.ts +++ b/packages/wallet/backend/src/walletAddress/service.ts @@ -2,6 +2,7 @@ import { Account } from '@/account/model' import { AccountService } from '@/account/service' import { Env } from '@/config/env' import { RafikiClient } from '@/rafiki/rafiki-client' +import { RhyzaAdminClient } from '@/rhyza/admin-client' import axios from 'axios' import { getRandomValues } from 'crypto' import { Cache, RedisClient } from '@shared/backend' @@ -77,6 +78,7 @@ export class WalletAddressService implements IWalletAddressService { constructor( private accountService: AccountService, private rafikiClient: RafikiClient, + private rhyzaAdminClient: RhyzaAdminClient, private env: Env, redisClient: RedisClient, private transactionService: TransactionService @@ -118,17 +120,26 @@ export class WalletAddressService implements IWalletAddressService { ) } } else { - const assetCode = account.assetCode - const rhyzaWalletAddress = - await this.rafikiClient.createRhyzaWalletAddress( - url, - assetCode, - args.publicName, - true - ) + const rhyzaWalletAddress = await this.rhyzaAdminClient + .createWalletAddress({ + address: url, + assetCode: account.assetCode, + publicName: args.publicName, + isActive: true + }) + .catch((e) => { + // Exists in Rhyza only, e.g. after a DB reset. + if (e instanceof Conflict) { + throw new Conflict( + 'This wallet address already exists. Please choose another name.' + ) + } + throw e + }) walletAddress = await WalletAddress.query().insert({ - url: rhyzaWalletAddress.address, + // Rhyza returns the address without a scheme. + url, publicName: args.publicName, accountId: args.accountId, id: rhyzaWalletAddress.id, diff --git a/packages/wallet/backend/tests/rhyza/admin-client.test.ts b/packages/wallet/backend/tests/rhyza/admin-client.test.ts index 25096bba4..85b615096 100644 --- a/packages/wallet/backend/tests/rhyza/admin-client.test.ts +++ b/packages/wallet/backend/tests/rhyza/admin-client.test.ts @@ -53,4 +53,50 @@ describe('RhyzaAdminClient', () => { ) }) }) + + describe('createWalletAddress', () => { + const args = { + address: 'https://ilp.test/alice', + assetCode: 'USD', + publicName: 'Alice', + isActive: true + } + + it('POSTs the address details to /wallet-addresses', async () => { + adapter.mockImplementationOnce( + respond(201, { id: 'wa-1', address: 'ilp.test/alice' }) + ) + + await client.createWalletAddress(args) + + const request = lastRequest(adapter) + expect(request.method).toBe('POST') + expect(request.url).toBe('http://admin.test/wallet-addresses') + expect(request.body).toEqual(args) + }) + + it('returns the Rhyza id and address on 201', async () => { + adapter.mockImplementationOnce( + respond(201, { id: 'wa-1', address: 'ilp.test/alice' }) + ) + + await expect(client.createWalletAddress(args)).resolves.toEqual({ + id: 'wa-1', + address: 'ilp.test/alice' + }) + }) + + it('raises Conflict on 409', async () => { + adapter.mockImplementationOnce( + respond(409, { + error: + '13 INTERNAL: Wallet Address with that domain and pathName ilp.test/alice already exists' + }) + ) + + await expect(client.createWalletAddress(args)).rejects.toBeInstanceOf( + Conflict + ) + }) + }) }) diff --git a/packages/wallet/backend/tests/rhyza/helpers.ts b/packages/wallet/backend/tests/rhyza/helpers.ts index 559fc69ca..0e98ef611 100644 --- a/packages/wallet/backend/tests/rhyza/helpers.ts +++ b/packages/wallet/backend/tests/rhyza/helpers.ts @@ -13,7 +13,7 @@ export const mockLogger = () => error: jest.fn() }) as unknown as Logger -// Stands in for axios's network adapter; must be installed before the HttpClient is built. +// Install before building the HttpClient. export const mockAdapter = () => { const original = axios.defaults.adapter const adapter = jest.fn< @@ -29,7 +29,7 @@ export const mockAdapter = () => { } } -// Mirrors axios's own settle(): non-2xx rejects with an AxiosError carrying the response. +// Mirrors axios's settle(): non-2xx rejects. export const respond = (status: number, body?: unknown) => async (config: InternalAxiosRequestConfig): Promise => { diff --git a/packages/wallet/backend/tests/walletAddress/service.test.ts b/packages/wallet/backend/tests/walletAddress/service.test.ts index edf53aa4f..98cbfa9ff 100644 --- a/packages/wallet/backend/tests/walletAddress/service.test.ts +++ b/packages/wallet/backend/tests/walletAddress/service.test.ts @@ -14,7 +14,7 @@ import { WalletAddressService } from '@/walletAddress/service' import { WalletAddress } from '@/walletAddress/model' import { Logger } from 'winston' import { AwilixContainer } from 'awilix' -import { NotFound } from '@shared/backend' +import { Conflict, NotFound } from '@shared/backend' describe('Wallet Address Service', () => { let bindings: AwilixContainer @@ -79,16 +79,18 @@ describe('Wallet Address Service', () => { id: faker.string.uuid(), address: faker.internet.url() }), - createRhyzaWalletAddress: () => ({ - id: faker.string.uuid(), - address: faker.internet.url() - }), createRafikiWalletAddressKey: () => ({ id: faker.string.uuid() }), revokeWalletAddressKey: jest.fn(), updateWalletAddress: jest.fn() }, + rhyzaAdminClient: { + createWalletAddress: jest.fn(async () => ({ + id: faker.string.uuid(), + address: faker.internet.domainName() + })) + }, updateTransaction: jest.fn() } @@ -166,6 +168,57 @@ describe('Wallet Address Service', () => { }) }) + it('should create the WalletAddress in Rhyza and store our https URL', async () => { + const { account } = await prepareWADependencies('my-work') + const expectedUrl = `${serviceEnv.OPEN_PAYMENTS_HOST.replace(/^http:\/\//, 'https://')}/my-wallet` + const createWalletAddress = jest.mocked( + Reflect.get(waService, 'rhyzaAdminClient').createWalletAddress + ) + const rhyzaId = faker.string.uuid() + createWalletAddress.mockResolvedValueOnce({ + id: rhyzaId, + address: 'backend/my-wallet' + }) + + const result = await waService.create({ + userId, + accountId: account.id, + walletAddressName: 'my-wallet', + publicName: 'My Wallet' + }) + + expect(createWalletAddress).toHaveBeenCalledWith({ + address: expectedUrl, + assetCode: account.assetCode, + publicName: 'My Wallet', + isActive: true + }) + expect(result).toMatchObject({ + id: rhyzaId, + url: expectedUrl + }) + }) + + it('should return repetitive err when the address exists only in Rhyza', async () => { + const { account } = await prepareWADependencies('my-work') + jest + .mocked(Reflect.get(waService, 'rhyzaAdminClient').createWalletAddress) + .mockRejectedValueOnce( + new Conflict('13 INTERNAL: Wallet Address ... already exists') + ) + + await expect( + waService.create({ + userId, + accountId: account.id, + walletAddressName: 'my-wallet', + publicName: 'My Wallet' + }) + ).rejects.toThrow( + /^This wallet address already exists. Please choose another name.$/ + ) + }) + it('should return repetitive err', async () => { const { account } = await prepareWADependencies('my-work', false)