diff --git a/CLAUDE.md b/CLAUDE.md index 1d753bef..94655989 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -255,14 +255,14 @@ Before committing or pushing, run these checks: ## Related Repos -| Repo | Purpose | Docs | -| ----------------------------- | --------------------------------------------- | ------------------------------------------------------------------------ | -| `canton` | Trading infrastructure, ADRs | `AGENTS.md` | -| `canton-explorer` | Next.js explorer UI | `AGENTS.md`, [cantonops.fairmint.com](https://cantonops.fairmint.com/) | -| `canton-fairmint-sdk` | Shared TypeScript utilities | `AGENTS.md` | -| `ocp-canton-sdk` | High-level OCP TypeScript SDK | `AGENTS.md`, [ocp.canton.fairmint.com](https://ocp.canton.fairmint.com/) | -| `ocp-equity-certificate` | Soulbound equity certificate smart contracts | `AGENTS.md` | -| `open-captable-protocol-daml` | DAML contracts (OCF impl) | `AGENTS.md` | +| Repo | Purpose | Docs | +| ----------------------------- | -------------------------------------------- | ------------------------------------------------------------------------ | +| `canton` | Trading infrastructure, ADRs | `AGENTS.md` | +| `canton-explorer` | Next.js explorer UI | `AGENTS.md`, [cantonops.fairmint.com](https://cantonops.fairmint.com/) | +| `canton-fairmint-sdk` | Shared TypeScript utilities | `AGENTS.md` | +| `ocp-canton-sdk` | High-level OCP TypeScript SDK | `AGENTS.md`, [ocp.canton.fairmint.com](https://ocp.canton.fairmint.com/) | +| `ocp-equity-certificate` | Soulbound equity certificate smart contracts | `AGENTS.md` | +| `open-captable-protocol-daml` | DAML contracts (OCF impl) | `AGENTS.md` | ## Docs diff --git a/package.json b/package.json index 93598be5..019a7ad6 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,8 @@ }, "dependencies": { "@canton-network/wallet-sdk": "0.21.0", + "@hardlydifficult/rest-client": "1.0.0", + "@hardlydifficult/websocket": "1.0.2", "@stellar/stellar-base": "14.0.4", "axios": "1.13.5", "dotenv": "17.2.4", diff --git a/src/core/auth/AuthenticationManager.ts b/src/core/auth/AuthenticationManager.ts index db992007..3dba3b09 100644 --- a/src/core/auth/AuthenticationManager.ts +++ b/src/core/auth/AuthenticationManager.ts @@ -1,6 +1,8 @@ -import axios from 'axios'; -import { URLSearchParams } from 'url'; -import { ApiError, AuthenticationError } from '../errors'; +import { + AuthenticationManager as RestClientAuthManager, + type AuthConfig as RestClientAuthConfig, + type RestClientLogger, +} from '@hardlydifficult/rest-client'; import { type Logger } from '../logging'; import { type AuthConfig } from '../types'; @@ -11,128 +13,20 @@ export interface AuthResponse { readonly scope?: string; } -/** Manages OAuth2 authentication and token lifecycle */ +/** + * Manages OAuth2 authentication and token lifecycle. Delegates to `@hardlydifficult/rest-client`'s + * AuthenticationManager internally. + */ export class AuthenticationManager { - private bearerToken: string | null = null; - private tokenExpiry: number | null = null; - private tokenIssuedAt: number | null = null; + private readonly delegate: RestClientAuthManager; - constructor( - private readonly authUrl: string, - private readonly authConfig: AuthConfig, - private readonly logger?: Logger - ) {} + constructor(authUrl: string, authConfig: AuthConfig, logger?: Logger) { + const restAuthConfig = convertAuthConfig(authUrl, authConfig); + this.delegate = new RestClientAuthManager(restAuthConfig, toRestLogger(logger)); + } public async authenticate(): Promise { - // Check if we have a valid token - if (this.isTokenValid() && this.bearerToken) { - return this.bearerToken; - } - - // Check for static bearer token first - if (this.authConfig.bearerToken) { - this.bearerToken = this.authConfig.bearerToken; - return this.bearerToken; - } - - // Check for token generator function - if (this.authConfig.tokenGenerator) { - this.bearerToken = await this.authConfig.tokenGenerator(); - // Tokens from generator may have short expiry, so don't cache for long - this.tokenIssuedAt = Date.now(); - this.tokenExpiry = this.tokenIssuedAt + 60 * 1000; // 1 minute cache - return this.bearerToken; - } - - // Check if OAuth2 authentication credentials are provided - if (!this.authConfig.clientId || this.authConfig.clientId.trim() === '') { - // No authentication credentials provided, skip authentication - this.bearerToken = null; - return ''; - } - - // Validate required auth configuration for OAuth2 - this.validateAuthConfig(); - - const formData = new URLSearchParams(); - formData.append('grant_type', this.authConfig.grantType); - formData.append('client_id', this.authConfig.clientId); - - if (this.authConfig.grantType === 'client_credentials' && this.authConfig.clientSecret) { - formData.append('client_secret', this.authConfig.clientSecret); - } - if (this.authConfig.audience) { - formData.append('audience', this.authConfig.audience); - } - if (this.authConfig.scope) { - formData.append('scope', this.authConfig.scope); - } - if (this.authConfig.grantType === 'password') { - formData.append('username', this.authConfig.username); - formData.append('password', this.authConfig.password); - } - - const url = `${this.authUrl}/`; - const headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; - - // Build a log-friendly representation of the request body - const requestBody: Record = {}; - for (const [key, value] of formData.entries()) { - requestBody[key] = value; - } - - try { - const response = await axios.post(url, formData.toString(), { - headers, - }); - - if (!response.data.access_token) { - throw new AuthenticationError( - `Authentication response missing access_token. Response: ${JSON.stringify(response.data, null, 2)}` - ); - } - - this.bearerToken = response.data.access_token; - this.tokenIssuedAt = Date.now(); - - // Set token expiry if provided - if (response.data.expires_in) { - this.tokenExpiry = this.tokenIssuedAt + response.data.expires_in * 1000; - } - - // Log success - if (this.logger) { - await this.logger.logRequestResponse(url, { method: 'POST', headers, data: requestBody }, response.data); - } - - return this.bearerToken; - } catch (error) { - // Log failure with context - if (this.logger) { - const errorPayload: unknown = axios.isAxiosError(error) - ? (error.response?.data ?? error.message) - : error instanceof Error - ? error.message - : String(error); - await this.logger.logRequestResponse(url, { method: 'POST', headers, data: requestBody }, errorPayload); - } - - if (axios.isAxiosError(error)) { - const status = error.response?.status; - const statusText = error.response?.statusText; - const errorData = error.response?.data ? JSON.stringify(error.response.data, null, 2) : error.message; - - throw new ApiError( - `Authentication failed for URL ${url} with status ${status} ${statusText}: ${errorData}`, - status, - statusText - ); - } - - throw new AuthenticationError( - `Authentication failed for URL ${url}: ${error instanceof Error ? error.message : String(error)}` - ); - } + return this.delegate.authenticate(); } public async getBearerToken(): Promise { @@ -140,76 +34,69 @@ export class AuthenticationManager { } public clearToken(): void { - this.bearerToken = null; - this.tokenExpiry = null; - this.tokenIssuedAt = null; + this.delegate.clearToken(); } - /** - * Returns the token expiry timestamp in milliseconds since epoch, or null if not available. Use this to schedule - * proactive token refresh before expiration. - */ public getTokenExpiryTime(): number | null { - return this.tokenExpiry; + return this.delegate.getTokenExpiryTime(); } - /** Returns the timestamp when the current token was issued, in milliseconds since epoch, or null if not available. */ public getTokenIssuedAt(): number | null { - return this.tokenIssuedAt; + return this.delegate.getTokenIssuedAt(); } - /** - * Returns the token lifetime in milliseconds, or null if not available. Calculated as the difference between token - * expiry time and issue time. - */ public getTokenLifetimeMs(): number | null { - if (this.tokenIssuedAt === null || this.tokenExpiry === null) { - return null; - } - return this.tokenExpiry - this.tokenIssuedAt; + return this.delegate.getTokenLifetimeMs(); } +} - private validateAuthConfig(): void { - // Runtime validation for grantType (TypeScript only provides compile-time checks) - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard for external config sources - if (!this.authConfig.grantType) { - throw new AuthenticationError(`Authentication configuration incomplete. Missing required field: grantType.`); - } - if (!this.authConfig.clientId) { - throw new AuthenticationError( - `Authentication configuration incomplete. Missing required field: clientId. ` + - `Grant Type: ${this.authConfig.grantType}` - ); - } - // Runtime validation for password grant type (TypeScript only provides compile-time checks) - if (this.authConfig.grantType === 'password') { - const missingFields: string[] = []; - if (!this.authConfig.username) { - missingFields.push('username'); - } - if (!this.authConfig.password) { - missingFields.push('password'); - } - if (missingFields.length > 0) { - throw new AuthenticationError( - `Authentication configuration incomplete for password grant type. Missing required field(s): ${missingFields.join(', ')}.` - ); - } - } +/** Converts Canton's auth config format to rest-client's auth config format. */ +function convertAuthConfig(authUrl: string, config: AuthConfig): RestClientAuthConfig { + if (config.bearerToken) { + return { type: 'bearer', token: config.bearerToken }; } - private isTokenValid(): boolean { - if (!this.bearerToken) { - return false; - } + if (config.tokenGenerator) { + return { type: 'generator', generate: config.tokenGenerator }; + } - // If no expiry is set, assume token is valid - if (!this.tokenExpiry) { - return true; - } + if (!config.clientId || config.clientId.trim() === '') { + return { type: 'none' }; + } - // Check if token has expired (with 5 minute buffer) - const bufferTime = 5 * 60 * 1000; // 5 minutes - return Date.now() < this.tokenExpiry - bufferTime; + const tokenUrl = authUrl.endsWith('/') ? authUrl : `${authUrl}/`; + + if (config.grantType === 'password') { + return { + type: 'oauth2' as const, + tokenUrl, + clientId: config.clientId, + grantType: 'password' as const, + username: config.username, + password: config.password, + ...(config.audience ? { audience: config.audience } : {}), + ...(config.scope ? { scope: config.scope } : {}), + }; } + + return { + type: 'oauth2' as const, + tokenUrl, + clientId: config.clientId, + grantType: 'client_credentials' as const, + ...(config.clientSecret ? { clientSecret: config.clientSecret } : {}), + ...(config.audience ? { audience: config.audience } : {}), + ...(config.scope ? { scope: config.scope } : {}), + }; +} + +/** Adapts Canton's Logger to rest-client's RestClientLogger. */ +function toRestLogger(logger: Logger | undefined): RestClientLogger | undefined { + if (!logger) return undefined; + const adapted: RestClientLogger = {}; + if (logger.debug) adapted.debug = logger.debug.bind(logger); + if (logger.info) adapted.info = logger.info.bind(logger); + if (logger.warn) adapted.warn = logger.warn.bind(logger); + if (logger.error) adapted.error = logger.error.bind(logger); + return adapted; } diff --git a/src/core/errors.ts b/src/core/errors.ts index f11c20ec..8143b891 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -1,3 +1,5 @@ +import { RestClientError } from '@hardlydifficult/rest-client'; + /** JSON-serializable context for error details. */ export type ErrorContext = Readonly>; @@ -13,19 +15,16 @@ export const ErrorCode = { export type ErrorCodeType = (typeof ErrorCode)[keyof typeof ErrorCode]; -/** Base error class for all Canton SDK errors. */ -export class CantonError extends Error { +/** + * Base error class for all Canton SDK errors. Extends `@hardlydifficult/rest-client`'s `RestClientError` so errors are + * compatible with both hierarchies. + */ +export class CantonError extends RestClientError { public override readonly name: string; - constructor( - message: string, - public readonly code: string, - public readonly context?: ErrorContext - ) { - super(message); + constructor(message: string, code: string, context?: ErrorContext) { + super(message, code, context); this.name = 'CantonError'; - // Maintains proper prototype chain for ES5 - Object.setPrototypeOf(this, new.target.prototype); } } diff --git a/src/core/ws/WebSocketClient.ts b/src/core/ws/WebSocketClient.ts index 11830cf7..0a968e8f 100644 --- a/src/core/ws/WebSocketClient.ts +++ b/src/core/ws/WebSocketClient.ts @@ -1,3 +1,4 @@ +import { calculateTokenRefreshTime } from '@hardlydifficult/websocket'; import WebSocket, { type RawData } from 'ws'; import { type BaseClient } from '../BaseClient'; import { WebSocketErrorUtils } from './WebSocketErrorUtils'; @@ -43,31 +44,8 @@ export interface WebSocketOptions { readonly onTokenRefreshNeeded?: () => { code?: number; reason?: string } | void; } -/** - * Calculate when to schedule token refresh. Uses the later of: - * - * - 50% of token lifetime (protects short-lived tokens) - * - 2 minutes before expiry (ensures adequate buffer for longer tokens) - * - * Examples: - * - * - 60-second token: refresh at 30s (50% rule wins) - * - 5-minute token: refresh at 3min (2-min buffer wins) - * - 1-hour token: refresh at 58min (2-min buffer wins) - * - * @param tokenIssuedAt - Timestamp when token was issued (ms since epoch) - * @param tokenExpiresAt - Timestamp when token expires (ms since epoch) - * @returns Timestamp when refresh should be scheduled (ms since epoch) - */ -export function calculateTokenRefreshTime(tokenIssuedAt: number, tokenExpiresAt: number): number { - const lifetimeMs = tokenExpiresAt - tokenIssuedAt; - const twoMinutesMs = 2 * 60 * 1000; - - const halfLifetime = tokenIssuedAt + Math.floor(lifetimeMs / 2); - const twoMinutesBefore = tokenExpiresAt - twoMinutesMs; - - return Math.max(halfLifetime, twoMinutesBefore); -} +// calculateTokenRefreshTime is imported from @hardlydifficult/websocket +export { calculateTokenRefreshTime } from '@hardlydifficult/websocket'; /** * Minimal WebSocket helper that: