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