parent
30412ff25f
commit
bc2d9bb842
@ -0,0 +1,43 @@ |
||||
import { GreenCheckClient } from './index'; |
||||
|
||||
// Mock fetch globally
|
||||
global.fetch = jest.fn(); |
||||
|
||||
describe('GreenCheckClient', () => { |
||||
beforeEach(() => { |
||||
jest.clearAllMocks(); |
||||
}); |
||||
|
||||
test('should instantiate with valid config', () => { |
||||
const client = new GreenCheckClient({ |
||||
authToken: 'test-token' |
||||
}); |
||||
|
||||
expect(client).toBeInstanceOf(GreenCheckClient); |
||||
}); |
||||
|
||||
test('should make phone verification request', async () => { |
||||
const mockResponse = { success: true }; |
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({ |
||||
ok: true, |
||||
json: () => Promise.resolve(mockResponse) |
||||
}); |
||||
|
||||
const client = new GreenCheckClient({ |
||||
authToken: 'test-token' |
||||
}); |
||||
|
||||
const result = await client.requestPhoneVerification('+12345678901'); |
||||
expect(result).toBe(true); |
||||
expect(global.fetch).toHaveBeenCalledWith( |
||||
expect.stringContaining('/api/gc-mobile/start-phone-claim'), |
||||
expect.objectContaining({ |
||||
method: 'POST', |
||||
headers: expect.objectContaining({ |
||||
'Authorization': 'test-token', |
||||
'Content-Type': 'application/json' |
||||
}) |
||||
}) |
||||
); |
||||
}); |
||||
}); |
@ -0,0 +1,193 @@ |
||||
import {
|
||||
GreenCheckId, |
||||
GreenCheckClientConfig, |
||||
PhoneClaimStartResponse, |
||||
PhoneClaimValidateResponse, |
||||
GreenCheckClaim, |
||||
ClaimsResponse, |
||||
GreenCheckError, |
||||
AuthSession, |
||||
AuthenticationError, |
||||
ValidationError, |
||||
RequestOptions |
||||
} from "./types" |
||||
|
||||
// Cross-platform fetch implementation
|
||||
function getGlobalFetch(): typeof fetch { |
||||
if (typeof globalThis !== 'undefined' && globalThis.fetch) { |
||||
return globalThis.fetch.bind(globalThis); |
||||
} |
||||
if (typeof window !== 'undefined' && window.fetch) { |
||||
return window.fetch.bind(window); |
||||
} |
||||
if (typeof global !== 'undefined' && (global as Record<string, unknown>).fetch) { |
||||
return ((global as Record<string, unknown>).fetch as typeof fetch).bind(global); |
||||
} |
||||
// For Node.js environments without fetch polyfill
|
||||
try { |
||||
const nodeFetch = require('node-fetch'); |
||||
return nodeFetch.default || nodeFetch; |
||||
} catch { |
||||
throw new Error('No fetch implementation found. Please install node-fetch for Node.js environments.'); |
||||
} |
||||
} |
||||
|
||||
// Cross-platform AbortSignal timeout
|
||||
function createTimeoutSignal(timeout: number): AbortSignal { |
||||
if (typeof AbortSignal !== 'undefined' && AbortSignal.timeout) { |
||||
return AbortSignal.timeout(timeout); |
||||
} |
||||
|
||||
// Fallback for environments without AbortSignal.timeout
|
||||
const controller = new AbortController(); |
||||
setTimeout(() => controller.abort(), timeout); |
||||
return controller.signal; |
||||
} |
||||
|
||||
export class GreenCheckClient { |
||||
private config: Required<GreenCheckClientConfig>; |
||||
private fetch: typeof fetch; |
||||
|
||||
constructor(config: GreenCheckClientConfig) { |
||||
this.config = { |
||||
serverUrl: 'https://greencheck.world', |
||||
timeout: 30000, |
||||
...config |
||||
}; |
||||
this.fetch = getGlobalFetch(); |
||||
} |
||||
|
||||
private formatPhone(phone: string): string | null { |
||||
let digits = phone.replace(/[^+\d]/g, ''); |
||||
|
||||
// Add country code if not present
|
||||
if (!digits.startsWith('+')) { |
||||
digits = `+1${digits}`; |
||||
} |
||||
|
||||
// Validate format (11+ digits with country code)
|
||||
if (!/^\+\d{11,}$/.test(digits)) { |
||||
return null; |
||||
} |
||||
|
||||
return digits; |
||||
} |
||||
|
||||
private async makeRequest<T>(options: RequestOptions): Promise<T> { |
||||
const url = `${this.config.serverUrl}${options.endpoint}`; |
||||
|
||||
const headers = { |
||||
'Authorization': this.config.authToken, |
||||
'Content-Type': 'application/json', |
||||
...options.headers |
||||
}; |
||||
|
||||
const fetchOptions: RequestInit = { |
||||
method: options.method, |
||||
headers, |
||||
signal: createTimeoutSignal(this.config.timeout) |
||||
}; |
||||
|
||||
if (options.body) { |
||||
fetchOptions.body = JSON.stringify(options.body); |
||||
} |
||||
|
||||
const response = await this.fetch(url, fetchOptions); |
||||
|
||||
if (!response.ok) { |
||||
const error = await response.json().catch(() => ({})); |
||||
throw new GreenCheckError( |
||||
error.message || `HTTP ${response.status}: ${response.statusText}`, |
||||
error.code, |
||||
response.status |
||||
); |
||||
} |
||||
|
||||
return await response.json(); |
||||
} |
||||
|
||||
async requestPhoneVerification(phone: string): Promise<boolean> { |
||||
const formattedPhone = this.formatPhone(phone); |
||||
|
||||
if (!formattedPhone) { |
||||
throw new ValidationError('Invalid phone number format. US/Canada numbers only.'); |
||||
} |
||||
|
||||
const response = await this.makeRequest<PhoneClaimStartResponse>({ |
||||
endpoint: '/api/gc-mobile/start-phone-claim', |
||||
method: 'POST', |
||||
body: { phone: formattedPhone } |
||||
}); |
||||
|
||||
return response.success; |
||||
} |
||||
|
||||
async verifyPhoneCode(phone: string, code: string): Promise<AuthSession> { |
||||
const formattedPhone = this.formatPhone(phone); |
||||
|
||||
if (!formattedPhone) { |
||||
throw new ValidationError('Invalid phone number format.'); |
||||
} |
||||
|
||||
const response = await this.makeRequest<PhoneClaimValidateResponse>({ |
||||
endpoint: '/api/gc-mobile/validate-phone-code', |
||||
method: 'POST', |
||||
body: { phone: formattedPhone, code } |
||||
}); |
||||
|
||||
if (!response.success || !response.authToken || !response.greenCheck) { |
||||
throw new AuthenticationError(response.error || 'Phone verification failed'); |
||||
} |
||||
|
||||
return { |
||||
authToken: response.authToken, |
||||
greenCheckId: response.greenCheck.greenCheckId |
||||
}; |
||||
} |
||||
|
||||
async getGreenCheckIdFromToken(authToken: string): Promise<string> { |
||||
const response = await this.makeRequest<{ greenCheck: GreenCheckId }>({ |
||||
endpoint: `/api/gc-mobile/id-for-token?token=${authToken}`, |
||||
method: 'GET' |
||||
}); |
||||
|
||||
if (!response.greenCheck) { |
||||
throw new AuthenticationError('No GreenCheck ID found for the provided token'); |
||||
} |
||||
|
||||
return response.greenCheck.greenCheckId; |
||||
} |
||||
|
||||
async getClaims(authToken: string): Promise<GreenCheckClaim[]> { |
||||
const greenCheckId = await this.getGreenCheckIdFromToken(authToken); |
||||
|
||||
const response = await this.makeRequest<ClaimsResponse>({ |
||||
endpoint: `/api/gc-mobile/claims-for-id?gcId=${greenCheckId}&token=${authToken}`, |
||||
method: 'GET' |
||||
}); |
||||
|
||||
return response.claims || []; |
||||
} |
||||
|
||||
async generateOTT(authToken: string): Promise<string> { |
||||
const response = await this.makeRequest<{ ott: string }>({ |
||||
endpoint: '/api/gc-mobile/register-ott', |
||||
method: 'POST', |
||||
body: { token: authToken } |
||||
}); |
||||
|
||||
return response.ott; |
||||
} |
||||
} |
||||
|
||||
// Default export
|
||||
export default GreenCheckClient; |
||||
export {
|
||||
GreenCheckId, |
||||
GreenCheckClientConfig, |
||||
PhoneClaimStartResponse, |
||||
PhoneClaimValidateResponse, |
||||
GreenCheckClaim, |
||||
ClaimsResponse, |
||||
AuthSession |
||||
} |
@ -0,0 +1,103 @@ |
||||
// Core GreenCheck Identity
|
||||
export interface GreenCheckId { |
||||
greenCheckId: string; |
||||
created: string; // ISO datetime
|
||||
lastAccess: string; // ISO datetime
|
||||
numAccesses: number; |
||||
username?: string; |
||||
} |
||||
|
||||
// Claim data structure
|
||||
export interface ClaimData { |
||||
id?: string | number; // provider ID
|
||||
username?: string; |
||||
avatar?: string; |
||||
image?: string; |
||||
description?: string; |
||||
fullname?: string; |
||||
location?: string | null; |
||||
url?: string; |
||||
[key: string]: unknown; // Allow additional fields
|
||||
} |
||||
|
||||
// GreenCheck claim
|
||||
export interface GreenCheckClaim { |
||||
_id: string; |
||||
greenCheckId: string; |
||||
created: string; // ISO datetime
|
||||
updated?: string; // ISO datetime
|
||||
provider: string; |
||||
firstClaim?: string; |
||||
numClaims?: number; |
||||
claimData: ClaimData; |
||||
} |
||||
|
||||
// API Response types
|
||||
export interface PhoneClaimStartResponse { |
||||
success: boolean; |
||||
error?: string; |
||||
} |
||||
|
||||
export interface PhoneClaimValidateResponse { |
||||
success: boolean; |
||||
authToken?: string; |
||||
greenCheck?: GreenCheckId; |
||||
error?: string; |
||||
} |
||||
|
||||
export interface ClaimsResponse { |
||||
claims: GreenCheckClaim[]; |
||||
} |
||||
|
||||
// Authentication session
|
||||
export interface AuthSession { |
||||
authToken: string; |
||||
greenCheckId: string; |
||||
expiresAt?: Date; |
||||
} |
||||
|
||||
// Client configuration
|
||||
export interface GreenCheckClientConfig { |
||||
serverUrl?: string; |
||||
authToken: string; |
||||
timeout?: number; |
||||
} |
||||
|
||||
// Error classes
|
||||
export class GreenCheckError extends Error { |
||||
public code?: string; |
||||
public statusCode?: number; |
||||
|
||||
constructor( |
||||
message: string, |
||||
code?: string, |
||||
statusCode?: number |
||||
) { |
||||
super(message); |
||||
this.name = 'GreenCheckError'; |
||||
this.code = code; |
||||
this.statusCode = statusCode; |
||||
} |
||||
} |
||||
|
||||
export class AuthenticationError extends GreenCheckError { |
||||
constructor(message: string) { |
||||
super(message, 'AUTHENTICATION_ERROR', 401); |
||||
this.name = 'AuthenticationError'; |
||||
} |
||||
} |
||||
|
||||
export class ValidationError extends GreenCheckError { |
||||
constructor(message: string) { |
||||
super(message, 'VALIDATION_ERROR', 400); |
||||
this.name = 'ValidationError'; |
||||
} |
||||
} |
||||
|
||||
// Cross-platform HTTP client
|
||||
export interface RequestOptions { |
||||
endpoint: string; |
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE'; |
||||
body?: unknown; |
||||
headers?: Record<string, string>; |
||||
} |
Loading…
Reference in new issue