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:
lorentz 2026-07-10 17:32:25 -04:00
parent ed485d8bde
commit 1da08093eb

View 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}`);
}
}