From 1da08093ebc7eb3ced44f679f80860d4625ab81b Mon Sep 17 00:00:00 2001 From: lorentz Date: Fri, 10 Jul 2026 17:32:25 -0400 Subject: [PATCH] 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() 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 --- lib/services/pax8-client.ts | 79 +++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 lib/services/pax8-client.ts diff --git a/lib/services/pax8-client.ts b/lib/services/pax8-client.ts new file mode 100644 index 0000000..e578d3a --- /dev/null +++ b/lib/services/pax8-client.ts @@ -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 { + 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(path: string, retryCount = 0): Promise { + 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> { + return this.fetchJson>(`/companies?page=${page}&size=${size}`); + } +}