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