Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/wallet/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions packages/wallet/backend/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
26 changes: 26 additions & 0 deletions packages/wallet/backend/src/config/rhyza.ts
Original file line number Diff line number Diff line change
@@ -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 })
)
)
}
7 changes: 7 additions & 0 deletions packages/wallet/backend/src/createContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -81,6 +84,8 @@ export interface Cradle {
authGraphQLClient: GraphQLClient
rafikiClient: RafikiClient
rafikiAuthService: RafikiAuthService
rhyzaAdminClient: RhyzaAdminClient
rhyzaIdpClient: RhyzaIdpClient
accountService: AccountService
ratesService: RatesService
redisClient: RedisClient
Expand Down Expand Up @@ -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(),
Expand Down
33 changes: 0 additions & 33 deletions packages/wallet/backend/src/rafiki/rafiki-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
41 changes: 41 additions & 0 deletions packages/wallet/backend/src/rhyza/admin-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { HttpClient } from '@/rhyza/http-client'
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) {}

async createAsset(code: string, scale: number): Promise<Asset> {
const response = await this.http.post<AssetCreateResponse>('/assets', {
code,
scale
})
// The response carries only the code.
return { code: response.code, scale }
}

async createWalletAddress(
args: CreateWalletAddressArgs
): Promise<WalletAddress> {
const response = await this.http.post<WalletAddressCreateResponse>(
'/wallet-addresses',
args
)
return { id: response.id, address: response.address }
}
}
147 changes: 147 additions & 0 deletions packages/wallet/backend/src/rhyza/http-client.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
timeoutMs?: number
}

export interface RequestOptions {
body?: unknown
headers?: Record<string, string>
}

// Generic message for users; upstream status and body are kept for logs.
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,
// Lenient parse so plain-text error bodies survive.
responseType: 'text',
transformResponse: (data: string) => (data ? parseBody(data) : undefined)
})
}

get<T>(path: string, options?: RequestOptions): Promise<T> {
return this.request<T>('GET', path, options)
}

post<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T> {
return this.request<T>('POST', path, { ...options, body })
}

async request<T>(
method: Method,
path: string,
options: RequestOptions = {}
): Promise<T> {
const hasBody = options.body !== undefined
try {
const response = await this.axios.request<T>({
method,
url: path,
data: hasBody ? JSON.stringify(options.body) : undefined,
// Fastify rejects an empty body with a content type.
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 would log a blank line for this AxiosError.
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 uses `error`; Fastify uses `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<string, unknown>
if (typeof message === 'string') return message
if (typeof error === 'string') return error
}
return undefined
}

// 401/403 means our credentials are wrong, not the user's.
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)
}
}
40 changes: 40 additions & 0 deletions packages/wallet/backend/src/rhyza/idp-client.ts
Original file line number Diff line number Diff line change
@@ -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<Grant> {
try {
const response = await this.http.get<GrantResponse>(
`/grant/${encodeURIComponent(id)}`
)
return {
id: response.id,
state: response.state,
access: response.access,
subjectIds: response.subject?.sub_ids ?? []
}
} catch (e) {
// 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
}
}
}

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
}
Loading
Loading