feat(10-01): implement Pax8Client token exchange + auth-proof call
- getToken() JSON-body OAuth2 client-credentials exchange with audience field
(deviates from msgraph-client.ts's form-encoded body per 10-RESEARCH.md Pitfall 3)
- 60s expiry-buffer token cache, reused across calls
- fetchJson<T>() with 429/Retry-After retry copied from msgraph-client.ts
- listCompanies() auth-proof call parsing the {content,page} envelope
- secret never interpolated into any throw/console call
This commit is contained in:
parent
ed485d8bde
commit
1da08093eb
1 changed files with 79 additions and 0 deletions
79
lib/services/pax8-client.ts
Normal file
79
lib/services/pax8-client.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* PAX8 REST API Client (v1)
|
||||
* OAuth2 client-credentials flow for partner/reseller reads.
|
||||
* https://devx.pax8.com
|
||||
*/
|
||||
|
||||
import type { Pax8Company, Pax8PageEnvelope } from '@/lib/types/pax8';
|
||||
|
||||
export interface Pax8ClientConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export class Pax8Client {
|
||||
private config: Pax8ClientConfig;
|
||||
private accessToken: string | null = null;
|
||||
private tokenExpiry: number = 0;
|
||||
|
||||
constructor(config: Pax8ClientConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
private async getToken(): Promise<string> {
|
||||
if (this.accessToken && Date.now() < this.tokenExpiry - 60000) {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
// PAX8 deviation from msgraph-client.ts — JSON body + audience field,
|
||||
// NOT application/x-www-form-urlencoded + URLSearchParams.
|
||||
const res = await fetch('https://api.pax8.com/v1/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
audience: 'https://api.pax8.com', // partner/reseller audience — NOT api://provisioning
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`PAX8 token request failed: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
this.accessToken = data.access_token;
|
||||
this.tokenExpiry = Date.now() + data.expires_in * 1000;
|
||||
return this.accessToken!;
|
||||
}
|
||||
|
||||
// Phase 11/12 will extend this with 429-aware Retry-After backoff for the
|
||||
// account-wide 1000/min rate limit (10-RESEARCH.md Pitfall 4) — not needed
|
||||
// for this phase's single auth-proof call.
|
||||
private async fetchJson<T>(path: string, retryCount = 0): Promise<T> {
|
||||
const token = await this.getToken();
|
||||
const res = await fetch(`https://api.pax8.com/v1${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (res.status === 429 && retryCount < 4) {
|
||||
const retryAfter = Math.max(30, parseInt(res.headers.get('Retry-After') || '30', 10));
|
||||
await new Promise(r => setTimeout(r, retryAfter * 1000));
|
||||
return this.fetchJson(path, retryCount + 1);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`PAX8 API error ${res.status} for ${path}: ${text}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Auth-proof read: list a page of companies. */
|
||||
async listCompanies(page = 0, size = 10): Promise<Pax8PageEnvelope<Pax8Company>> {
|
||||
return this.fetchJson<Pax8PageEnvelope<Pax8Company>>(`/companies?page=${page}&size=${size}`);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue