docs(10): research PAX8 client auth foundation

This commit is contained in:
lorentz 2026-07-10 12:59:06 -04:00
parent 0166a8583d
commit 8f814e2d94

View file

@ -0,0 +1,713 @@
# Phase 10: PAX8 Client & Auth Foundation - Research
**Researched:** 2026-07-10
**Domain:** OAuth2 client-credentials REST API integration + Postgres schema design (Pulse integration pattern)
**Confidence:** MEDIUM-HIGH (auth flow and endpoint shapes verified against official PAX8 docs; some response fields required a second-pass fetch due to docs requiring login for full OpenAPI render — see Assumptions Log)
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
**Orders / Invoices Schema**
- **D-01:** Two-table design — `pax8_orders` (header: order id, company,
order date, total, status) + `pax8_order_items` (line-item detail:
`order_id` FK, product/SKU, quantity, unit price, line total). Matches
PAX8's own order shape (an order has N line items) and avoids repeating
order-level fields on every line.
- **D-02:** No lookback-window bound. Sync (built in a later phase) pulls
full order history PAX8's API returns on first sync — no "earliest
synced" or window-config column needed in this phase's migration.
- **D-03:** Both `pax8_orders` and `pax8_order_items` carry a `raw_payload
JSONB` column alongside typed columns, as a safety net for PAX8 fields
not yet modeled. Mirrors the `itglue-search.ts` precedent of not
discarding API data even when only a subset is used today.
- **D-04:** Monetary amounts use `NUMERIC(12,2)` + a `currency CHAR(3)
DEFAULT 'USD'` column on both orders and line items — cheap insurance
against a future non-USD client without requiring a schema migration
later.
> **Research flag on D-01/D-04:** PAX8's actual `/orders` REST resource does
> not carry `total`/`status`/currency/unit-price fields — those live on a
> separate `/invoices` + `/invoices/{id}/items` resource. This does not
> override the locked table *shape* (still two tables, header + line items),
> but the *typed columns* should be modeled on PAX8's Invoice/InvoiceItem
> field names, not the bare Order/LineItem fields, or `total`/`status`/
> `unit_price` will never be populatable. See Common Pitfalls → Pitfall 1
> and Open Questions → Q1 below for full detail. Not blocking for Phase 10
> (schema only); needs a one-line confirmation before Phase 12 wires up sync.
### Claude's Discretion
The user chose to discuss only Orders/Invoices granularity. The following
gray areas were surfaced but explicitly left to the planner/executor,
default to the closest existing codebase pattern:
- **Company match/review table shape** — model on `device_link_review`
(migration `080_device_xref_company_id.sql`): a conflicts/review queue
with candidate match(es), confidence, `resolved_at`/`resolved_by_user_id`/
`resolution_note`. Adapt field names for PAX8 companies →
Autotask companies instead of device → CI matching. This table is
created in this phase's migration but populated by Phase 12's matching
logic — schema should anticipate that consumer without over-designing it.
- **Product catalog scope** — no explicit decision; planner may choose
full-catalog sync or lazy/referenced-only population. This is a Phase
11 sync-service decision more than a Phase 10 schema decision — the
`pax8_products` table shape should accommodate either without requiring
a redesign (e.g., don't add a "referenced only" constraint at the schema
level).
- **Raw payload retention on other tables** — whether `pax8_companies`,
`pax8_subscriptions`, and `pax8_products` also get a `raw_payload`
JSONB column. Given D-03 established this pattern for orders, applying
it consistently across all four PAX8 tables is a reasonable default
unless the planner has a specific reason not to (e.g., a table is fully
and confidently typed).
### Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope. (Company matching logic,
product catalog population strategy, the `/pax8` UI, and scheduler wiring
are already sequenced into Phases 11-14 per ROADMAP.md and REQUIREMENTS.md
— not deferred from this discussion, just out of this phase's boundary.)
</user_constraints>
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|-------------------|
| PAX8-01 | Pulse authenticates to the PAX8 REST API (`api.pax8.com/v1`) via OAuth2 client-credentials, using the developer-provisioned client ID/secret | Architecture Patterns → Pattern 1 (token exchange + cache) and Pattern 3 (paginated auth-proof call); Code Examples → full `Pax8Client` skeleton; Common Pitfalls → Pitfall 2 (audience value) and Pitfall 3 (JSON vs form-encoded body) |
| PAX8-02 | `isPax8Configured()` helper reports whether PAX8 credentials are present, following the existing `is<Name>Configured()` factory pattern (`lib/services/pax8-factory.ts`) | Architecture Patterns → Pattern 2 (factory + throw-if-missing, modeled on `appgate-factory.ts`/`msgraph-factory.ts`); Environment Availability (confirms `PAX8_CLIENT_ID`/`PAX8_CLIENT_SECRET` not yet in `.env`) |
</phase_requirements>
## Summary
PAX8 exposes a standard OAuth2 client-credentials REST API at `https://api.pax8.com/v1`, well
documented at `devx.pax8.com`. The auth flow is a direct match for
`lib/services/msgraph-client.ts` / `msgraph-factory.ts` (POST to a token endpoint, cache the
bearer token with an expiry buffer, attach `Authorization: Bearer <token>` to subsequent calls) —
CONTEXT.md's steer toward that template is correct and requires no material adaptation, with
one addition: **PAX8's token request requires an `audience` field** (`"https://api.pax8.com"`
for partner/reseller read access), which MSGraph's token request doesn't have an equivalent of.
Miss this field and the token exchange will fail with a scoping error, not a clear "missing
audience" message.
The most important finding for planning is **schema-shaping, not auth**: PAX8's `/orders`
endpoint (provisioning) does **not** carry pricing, currency, or status fields — those live on a
separate `/invoices` and `/invoices/{id}/items` resource. CONTEXT.md's locked decisions D-01/D-04
describe a `pax8_orders` + `pax8_order_items` two-table design with order-level `total`/`status`
and line-item `unit price`/`line total` — those monetary/status fields exist on PAX8's **Invoice**
and **Invoice Item** objects, not on PAX8's **Order** and **Line Item** objects. This doesn't
block Phase 10 (schema only, `IF NOT EXISTS`, no sync logic yet), but the planner should model the
typed columns using the Invoice/Invoice-Item field names documented below, since that's the data
that will actually populate `total`/`unit price`/`status` when Phase 12 builds the sync. Flagged
in detail under Common Pitfalls and Open Questions — this needs a one-line confirmation from the
user or an explicit call from the planner before Phase 12, but does not block Phase 10 migration
work.
On the company-identifier question (`RESEARCH-pax8-company-identifiers`): PAX8's `companies`
object exposes both a `website` field and an `externalId` field beyond `name`. `externalId` is
partner-writable — PAX8 doesn't auto-populate it with anything from Autotask; it's a slot Pulse
itself could write into after a manual/fuzzy match resolves, giving Phase 12+ a stable
direct-lookup key going forward instead of re-fuzzy-matching every sync. This is worth keeping in
mind for the company-match/review table shape now, even though Phase 10 doesn't populate it.
**Primary recommendation:** Follow `msgraph-client.ts`/`msgraph-factory.ts` verbatim for the auth
shape (add the `audience` field to the token POST body), and build the migration's typed columns
against the **Invoice / Invoice Item** field names for the "orders/invoices" tables — not the
bare Order/LineItem fields, which lack pricing entirely.
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| OAuth2 token exchange + caching | API/Backend (service layer) | — | Server-only secret handling; matches `msgraph-client.ts` — never exposed to browser |
| PAX8 REST calls (companies/subscriptions/products/invoices) | API/Backend (service layer) | — | `lib/services/pax8-client.ts`, called only from server-side factory/sync code, no client-side fetch |
| Config presence check (`isPax8Configured()`) | API/Backend (service layer) | — | Pure env-var check, used by future admin/health routes, not by this phase's UI (none exists yet) |
| Schema (4 entity tables + review queue) | Database/Storage | — | Postgres migration, `IF NOT EXISTS`, no ORM — raw SQL per project convention |
| Company match/review queue | Database/Storage | API/Backend (future, Phase 14) | Table created now; read/write logic and UI are out of scope until Phase 12 (write) / 14 (UI) |
This phase touches only the Backend/Service and Database tiers — no Browser, Frontend-SSR, or CDN
concerns apply (`UI hint: no` per ROADMAP.md, confirmed in CONTEXT.md).
## Standard Stack
### Core
No new npm packages are required for this phase. PAX8 auth + REST calls use the same native
`fetch` (Node 18+ global, already relied on by `msgraph-client.ts`) and `URLSearchParams` used
throughout the existing integration clients. Schema work is raw SQL via the existing
`postgresClient` singleton — no ORM, per project convention.
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|---------------|
| (none — native `fetch`) | Node 18+ built-in | HTTP calls to PAX8 REST API | Matches `msgraph-client.ts`, `qbo-client.ts`; no HTTP client library used anywhere in `lib/services/` |
| `pg` | 8.11.0 (already installed) | Postgres access via `postgresClient` singleton | Existing project convention — no ORM |
### Supporting
Not applicable — this phase adds no new supporting libraries.
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Native `fetch` for PAX8 calls | `axios` or PAX8's community PowerShell/Python wrappers | No existing Pulse integration uses a third-party HTTP client; would be the only integration to deviate — rejected, matches zero precedent in `lib/services/` |
| `URLSearchParams`-encoded token body | `application/json` body | **PAX8's token endpoint requires `Content-Type: application/json`**, unlike MSGraph's `application/x-www-form-urlencoded` — see Code Examples below, this is a real API-shape difference to adapt, not a style choice |
**Installation:** No install step — no new dependencies.
## Package Legitimacy Audit
Not applicable. This phase installs zero external packages (native `fetch`, existing `pg` /
`postgresClient`). The Package Legitimacy Gate is skipped per its own trigger condition
("whenever this phase installs external packages").
## Architecture Patterns
### System Architecture Diagram
```
env vars (PAX8_CLIENT_ID, PAX8_CLIENT_SECRET)
isPax8Configured() ── false ──► throw (callers must check first, or getPax8Client() throws)
│ true
getPax8Client() (lazy singleton, lib/services/pax8-factory.ts)
Pax8Client.getToken()
├─ cached token valid (now < expiry - buffer)? return cached access_token
└─ else: POST https://api.pax8.com/v1/token
{ grant_type: client_credentials, client_id, client_secret,
audience: "https://api.pax8.com" }
◄── { access_token, token_type, expires_in, scope, expires_at }
cache token + (now + expires_in*1000)
Pax8Client.fetchJson(path) e.g. GET /companies?page=0&size=10
Authorization: Bearer <access_token>
{ content: [ ...companies ], page: { size, totalElements, totalPages, number } }
(Phase 10 stops here — proves the round trip. No persistence of fetched
data in this phase; migration below is schema-only, populated by Phase 11/12 sync services.)
Postgres migration 091_pax8_tables.sql (independent of the client — schema only)
pax8_companies
pax8_subscriptions
pax8_products
pax8_orders ──1:N──► pax8_order_items (or invoices/invoice_items — see Common Pitfalls)
pax8_company_match_review (FK → pax8_companies, nullable FK → companies (Autotask))
```
### Recommended Project Structure
```
lib/
├── services/
│ ├── pax8-client.ts # PAX8Client class: getToken(), fetchJson<T>(), typed entity methods
│ └── pax8-factory.ts # isPax8Configured(), getPax8Client(), resetPax8Client()/_resetPax8Client()
├── types/
│ └── pax8.ts # Company, Subscription, Product, Order, Invoice, InvoiceItem interfaces
migrations/
└── 091_pax8_tables.sql # pax8_companies, pax8_subscriptions, pax8_products, pax8_orders,
# pax8_order_items, pax8_company_match_review — all IF NOT EXISTS
```
### Pattern 1: OAuth2 Client-Credentials with Expiry-Aware Token Cache
**What:** A private `getToken()` method checks an in-memory cached token against `Date.now()`
minus a safety buffer before re-requesting; only re-authenticates when the cache is empty or
stale.
**When to use:** Any server-side OAuth2 client-credentials integration (this is PAX8's exact
flow — no refresh tokens, no user context, single-tenant per app registration).
**Example (adapted from `lib/services/msgraph-client.ts`, adjusted for PAX8's JSON body + `audience` field):**
```typescript
// Source: lib/services/msgraph-client.ts (existing Pulse pattern) +
// https://devx.pax8.com/docs/authentication (PAX8 token contract)
private async getToken(): Promise<string> {
if (this.accessToken && Date.now() < this.tokenExpiry - 60_000) {
return this.accessToken;
}
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; // expires_in seconds (86400 = 24h)
return this.accessToken!;
}
```
### Pattern 2: `is<Name>Configured()` + Throw-If-Missing Factory Singleton
**What:** Factory exports a boolean config-presence check plus a lazy singleton getter that
throws a clear error naming the missing env vars.
**When to use:** Every external integration in this codebase — no exceptions, no deviation
expected for PAX8.
**Example (adapted from `lib/services/appgate-factory.ts`, the most recently added integration —
closest live template):**
```typescript
// Source: lib/services/appgate-factory.ts, lib/services/msgraph-factory.ts (Pulse patterns)
import { Pax8Client } from './pax8-client';
let _client: Pax8Client | null = null;
export function isPax8Configured(): boolean {
return Boolean(process.env.PAX8_CLIENT_ID && process.env.PAX8_CLIENT_SECRET);
}
export function getPax8Client(): Pax8Client {
if (_client) return _client;
if (!isPax8Configured()) {
throw new Error('PAX8 is not configured — set PAX8_CLIENT_ID and PAX8_CLIENT_SECRET');
}
_client = new Pax8Client({
clientId: process.env.PAX8_CLIENT_ID!,
clientSecret: process.env.PAX8_CLIENT_SECRET!,
});
return _client;
}
export function _resetPax8Client(): void {
_client = null;
}
```
### Pattern 3: Paginated List Endpoint (PAX8's `content` + `page` envelope)
**What:** Every PAX8 list endpoint (`/companies`, `/subscriptions`, `/products`, `/orders`,
`/invoices/{id}/items`) wraps results in `{ content: [...], page: { size, totalElements,
totalPages, number } }`. Page `number` is 0-indexed; `totalPages`/`totalElements` are natural
counts.
**When to use:** The auth-proof call in success criterion #2 (list companies) and every future
sync call in Phase 11+.
**Example:**
```typescript
// Source: https://devx.pax8.com/docs/public-api-details (pagination contract, verified via
// devx.pax8.com/reference/findcompanies.md response schema)
async listCompanies(page = 0, size = 200): Promise<{ content: Pax8Company[]; totalPages: number }> {
const token = await this.getToken();
const res = await fetch(`https://api.pax8.com/v1/companies?page=${page}&size=${size}`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
if (!res.ok) {
const text = await res.text();
throw new Error(`PAX8 API error ${res.status} for /companies: ${text}`);
}
const data = await res.json();
return { content: data.content, totalPages: data.page.totalPages };
}
```
### Anti-Patterns to Avoid
- **Using `api://provisioning` or `api://usage` as the token audience:** those are for PAX8
*marketplace vendors* (companies selling products through PAX8), not partners/resellers
consuming the general API. Pulse is a partner consuming companies/subscriptions/orders/
invoices — the correct audience is `https://api.pax8.com`. Using the wrong audience will
produce a token that PAX8 accepts but that gets rejected (403) on the partner endpoints this
phase needs to call, which is a confusing failure mode to debug blind.
- **Modeling `pax8_orders`/`pax8_order_items` purely off the `/orders` endpoint's documented
fields:** doing so silently produces a table with no way to ever populate `total`, `status`,
`currency`, or unit pricing — see Common Pitfalls below.
- **URL-encoded form body for the PAX8 token POST:** PAX8's token endpoint expects
`Content-Type: application/json` with a JSON body, unlike MSGraph's
`application/x-www-form-urlencoded` `URLSearchParams` body. Copying `msgraph-client.ts`'s
`getToken()` verbatim without changing the body encoding will produce a PAX8 4xx error.
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Token expiry tracking | A custom cron/timer to pre-refresh tokens | In-memory `tokenExpiry` timestamp checked lazily on each call (existing pattern in every OAuth2 client in this codebase) | Simpler, matches every other integration, no background timer to leak or double-fire |
| Pagination looping | A generic paginator utility | Simple `while` loop reading `page.totalPages`/`page.number` per call site, mirroring `msgraph-client.ts`'s `getUsers()` `@odata.nextLink` loop pattern (adapted to PAX8's numeric page index) | No shared paginator utility exists in this codebase; each client hand-rolls its own loop matching its API's specific pagination shape — consistent with project convention, not a gap to fill |
| Company fuzzy-matching | Any matching logic in this phase | Nothing — explicitly out of scope (Phase 12) | Phase 10 only creates the empty `pax8_company_match_review` table; do not add matching logic, indexes for matching queries beyond what `device_link_review` already demonstrates, or a resolution API |
**Key insight:** This phase is deliberately thin — proving the auth handshake and laying down
schema. Nearly every "don't hand-roll" temptation here (retry logic, pagination helpers, matching
algorithms) belongs to a later phase; Phase 10's job is to not preclude them, not to build them.
## Common Pitfalls
### Pitfall 1: Orders API has no pricing/status — Invoices API does
**What goes wrong:** A migration is written with `pax8_orders.total NUMERIC(12,2)` and
`pax8_orders.status TEXT`, matching CONTEXT.md's D-01 description literally
("header: order id, company, order date, total, status"). When Phase 11/12 sync logic is later
written against PAX8's actual `/orders` endpoint, those columns can never be populated — the
Order object PAX8 returns has no `total`, `currencyCode`, or `status` field. The `raw_payload`
JSONB safety net (D-03) doesn't help here since the *source data itself* lacks the field, not
just the typed-column mapping.
**Why it happens:** "Orders" in PAX8's API is a provisioning action (what was ordered, when,
by whom) — separate from "Invoices" (what was billed, at what price, with what status:
Draft/Approved/Credited). CONTEXT.md's naming ("Orders / Invoices Schema") conflates the two,
which is a very natural assumption to make from the outside (most PSA-adjacent systems use
"order" and "invoice" interchangeably) but PAX8 genuinely splits them into two resources with
non-overlapping field sets.
**How to avoid:** Model the typed columns on `pax8_orders`/`pax8_order_items` after the
**Invoice** and **Invoice Item** objects documented below (which do carry `total`, `status`
(via context — see Assumptions Log A2), `currencyCode`, `price`, `subTotal`, `amountDue`), not
the bare Order/LineItem objects. Table *names* can stay `pax8_orders`/`pax8_order_items` per
D-01's locked decision (renaming is a cosmetic call, not required), but the *column* set should
reflect Invoice/InvoiceItem fields, since that's what Phase 12 will actually have to sync for
"cost reconciliation over time" (PAX8-06's stated goal). Flag this explicitly to the user/planner
before Phase 12 locks in a sync-source endpoint — Phase 10 can proceed with either interpretation
since `IF NOT EXISTS` migrations are cheap to correct if this needs revisiting, but getting the
column set right the first time avoids a Phase-12 rename/backfill migration.
**Warning signs:** If Phase 11/12's sync-service PLAN.md references calling `GET /orders` (not
`/invoices/{id}/items`) to populate `total`/`unit_price`/`status` columns, that's the mismatch
surfacing — flag before implementation.
### Pitfall 2: Wrong OAuth2 `audience` value silently issues a token that fails later
**What goes wrong:** Following MSGraph's token-request shape exactly (no `audience` field, or
guessing `api://provisioning` because PAX8's docs mention it prominently in generic
"Authentication" walkthroughs aimed at marketplace vendors) returns a *valid* 200 token response
— the failure doesn't surface until the first real API call (e.g. `GET /companies`) returns 403.
**Why it happens:** PAX8 serves multiple integration personas (marketplace vendors doing
provisioning/usage reporting vs. partners/resellers consuming their own purchased-company data)
from the same token endpoint, disambiguated only by the `audience` field. The docs default
examples skew toward the vendor-provisioning use case.
**How to avoid:** Use `audience: "https://api.pax8.com"` for all calls this phase needs
(companies, subscriptions, products, orders, invoices) — confirmed as "the correct audience...
for the partner endpoints" per PAX8's official Create Access Token reference.
**Warning signs:** Token request returns 200, but the first `GET /companies` call (success
criterion #2's auth-proof) returns 403 Forbidden rather than a 401 auth failure.
### Pitfall 3: Token `Content-Type` mismatch with the MSGraph template
**What goes wrong:** Copying `msgraph-client.ts`'s `getToken()` body-encoding
(`application/x-www-form-urlencoded` + `URLSearchParams`) verbatim causes PAX8's token endpoint
to reject the request (PAX8 expects a JSON body).
**Why it happens:** MSGraph's `/oauth2/v2.0/token` endpoint is the Microsoft identity platform's
generic OAuth2 token endpoint (form-encoded, per RFC 6749 convention). PAX8 built a
custom `/v1/token` endpoint that departs from that convention and expects
`Content-Type: application/json`.
**How to avoid:** Use `JSON.stringify({...})` with `Content-Type: application/json`, not
`URLSearchParams` — see Code Examples / Pattern 1 above.
**Warning signs:** 400/415 response from `POST /v1/token` when the request body looks
superficially correct.
### Pitfall 4: Rate limit is per-minute across the whole PAX8 account, not per-endpoint
**What goes wrong:** A future sync service (Phase 11+) that fans out concurrent requests across
companies/subscriptions/products/orders could exceed PAX8's documented 1000 calls/minute limit
and get 429s. Not a Phase 10 concern operationally (this phase makes at most one auth-proof call),
but worth noting in the client so retry/backoff isn't overlooked when sync logic lands.
**Why it happens:** 1000/min sounds generous until pagination (`size` max 200) across four
entity types with company-scoped filtering multiplies call count.
**How to avoid:** Not required for Phase 10. Note in code comments for Phase 11/12 to add
429-aware backoff, mirroring `msgraph-client.ts`'s existing `Retry-After`-respecting retry logic
in `fetchJson()`.
**Warning signs:** N/A for this phase — informational for downstream phases only.
## Code Examples
### Token exchange + auth-proof call (success criteria #2 combined)
```typescript
// Source: https://devx.pax8.com/docs/authentication,
// https://devx.pax8.com/reference/createaccesstoken (token contract),
// https://devx.pax8.com/reference/findcompanies.md (companies response shape)
export interface Pax8ClientConfig {
clientId: string;
clientSecret: string;
}
export class Pax8Client {
private config: Pax8ClientConfig;
private accessToken: string | null = null;
private tokenExpiry = 0;
constructor(config: Pax8ClientConfig) {
this.config = config;
}
private async getToken(): Promise<string> {
if (this.accessToken && Date.now() < this.tokenExpiry - 60_000) {
return this.accessToken;
}
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',
}),
});
if (!res.ok) {
throw new Error(`PAX8 token request failed: ${res.status} ${await res.text()}`);
}
const data = await res.json();
this.accessToken = data.access_token;
this.tokenExpiry = Date.now() + data.expires_in * 1000;
return this.accessToken!;
}
/** Auth-proof read: list first page of companies. */
async listCompanies(page = 0, size = 10) {
const token = await this.getToken();
const res = await fetch(`https://api.pax8.com/v1/companies?page=${page}&size=${size}`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
if (!res.ok) {
throw new Error(`PAX8 API error ${res.status} for /companies: ${await res.text()}`);
}
return res.json(); // { content: Pax8Company[], page: {...} }
}
}
```
### Migration skeleton (schema only — column set per Invoice/InvoiceItem, see Pitfall 1)
```sql
-- Source: field names from https://devx.pax8.com/reference/findcompanies.md,
-- findsubscriptions.md, findallproducts.md, findpartnerinvoiceitems (invoice items —
-- used for pax8_orders/pax8_order_items per Pitfall 1), getpartnerinvoice (invoice header)
-- Table shape convention: migrations/080_device_xref_company_id.sql (device_link_review)
CREATE TABLE IF NOT EXISTS pax8_companies (
id UUID PRIMARY KEY, -- PAX8's own company id
name TEXT NOT NULL,
external_id TEXT, -- partner-writable; see Open Questions
website TEXT,
status TEXT, -- Active | Inactive | Deleted
city TEXT,
state_or_province TEXT,
postal_code TEXT,
country TEXT,
raw_payload JSONB,
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS pax8_company_match_review (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pax8_company_id UUID NOT NULL REFERENCES pax8_companies(id) ON DELETE CASCADE,
candidate_company_ids BIGINT[] NOT NULL, -- Autotask companies.id candidates
match_confidences TEXT[] NOT NULL,
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMPTZ,
resolved_by_user_id TEXT REFERENCES "user"(id) ON DELETE SET NULL,
resolved_to_company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL,
resolution_note TEXT
);
-- (full column set, indexes for pax8_subscriptions/pax8_products/pax8_orders/pax8_order_items
-- intentionally left to the planner/executor — this is illustrative of the shape, not the
-- complete migration)
```
## State of the Art
Not materially applicable — PAX8's public API is a stable, actively-maintained REST API (docs
last referenced expiry examples dated in the 2024-2026 range, no deprecation notices found for
`/v1` endpoints during this research pass).
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|---------------|--------|
| N/A | N/A | — | No versioning migration found for PAX8's public API during this research |
**Deprecated/outdated:**
- `altVendorSku` on the Product object is marked `deprecated` in PAX8's own schema — don't rely
on it if/when Phase 11 types the product catalog; use `sku`/`vendorSku` instead.
## Assumptions Log
> Claims below were extracted via `WebFetch` summarization of PAX8's official `devx.pax8.com`
> documentation pages. This is treated as `[CITED: devx.pax8.com]` (official docs, but passed
> through an LLM summarization step rather than read verbatim by a human) rather than
> `[VERIFIED]`, since a couple of full JSON schemas (invoice, invoice items) required PAX8 login
> to render in full and were reconstructed from the OpenAPI-backed reference page's rendered
> markdown rather than a raw spec file. Cross-referenced by fetching the same class of page twice
> (`.md` suffix vs. plain URL) where results diverged, and by checking a third-party open-source
> PowerShell wrapper for endpoint-existence corroboration (not field-level, since it passes
> through JSON untyped).
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Token audience for partner/reseller reads is exactly `"https://api.pax8.com"` (not `api://provisioning`/`api://usage`) | Architecture Patterns (Pattern 1), Common Pitfalls (Pitfall 2) | If wrong, token exchange still succeeds (200) but subsequent API calls 403 — caught immediately by success criterion #2's auth-proof call, low blast radius, easy to detect and fix in this phase |
| A2 | Invoice object has a `status` field (e.g. `"Paid"`) even though it "appears in examples but is not present in the formally documented schema" per PAX8's own reference page note | Common Pitfalls (Pitfall 1), Code Examples (migration skeleton) | If PAX8 removed/renamed this field, the `pax8_orders.status` (sourced from Invoice) column may end up null for all rows until Phase 12's sync-service research re-verifies against a live API call — not a Phase 10 blocker since this phase does not populate the column |
| A3 | `externalId` on the Company object is genuinely partner-writable (not auto-populated by PAX8 from some upstream tenant ID) | Summary, Open Questions | If PAX8 actually populates `externalId` automatically with something useful (e.g. a Microsoft tenant ID for M365-reselling companies), Phase 12's matching strategy could use it as a read-only signal instead of a write-after-match slot — worth a live API call against production PAX8 credentials to check actual values on a few real companies before Phase 12 locks in the matching algorithm |
| A4 | PAX8's public API has no deprecated/versioned-away endpoints affecting companies/subscriptions/products/orders/invoices as of this research date | State of the Art | Low risk — no contrary evidence found; if PAX8 ships a `/v2` companies endpoint later, `/v1` is very likely to remain supported per typical REST versioning practice, but this wasn't explicitly confirmed in docs |
**Recommendation:** Given the developer already has live PAX8 client ID/secret provisioned (per
SEED-002), the single highest-value validation step before or during Phase 10 execution is
**one live `curl`/Node script call** to `/v1/token` then `/v1/companies?size=1` — this would
convert A1 from CITED to VERIFIED in about 30 seconds and is far more reliable than continued
documentation fetching. Recommend the planner include this as an early verification task in
Phase 10's plan (it directly demonstrates success criterion #2 anyway).
## Open Questions
1. **Should `pax8_orders`/`pax8_order_items` be sourced from PAX8's `/orders` API or
`/invoices` + `/invoices/{id}/items` API?**
- What we know: `/orders` has no pricing/status fields; `/invoices/{id}/items` has all the
fields D-01/D-04 describe (total, status via `amountDue`/invoice-level `status`, unit price
via `price`, currency via `currencyCode`).
- What's unclear: Whether the user's mental model when writing D-01 ("order id, company,
order date, total, status") was PAX8's literal `/orders` resource or the general concept of
"a billing event" (which maps to Invoices). CONTEXT.md's requirement PAX8-06 says
"orders/invoices (historical line items), enabling cost reconciliation over time" — the
"cost reconciliation" framing strongly suggests Invoices is the intended data source.
- Recommendation: Planner should name Phase 10's migration columns after Invoice/InvoiceItem
fields (this research's Pitfall 1 + migration skeleton above), and flag to the user during
Phase 12 planning (not Phase 10) that the sync will hit `/invoices/{id}/items`, not
`/orders` — a one-line confirmation, not a redesign.
2. **Does PAX8 actually populate `externalId` with anything useful out of the box, or is it
always null until a partner writes to it?**
- What we know: PAX8's docs reference `externalId` as a slot partners can use to store their
own identifiers (seen in the Microsoft-subscription-reconciliation guide, in a Microsoft
Graph/tenant-ID context specifically for M365 resale).
- What's unclear: Whether PAX8 itself ever writes a default value (e.g. derived from a
related vendor tenant) or whether it's always empty until a partner API call sets it.
- Recommendation: Not blocking for Phase 10 (the column exists either way — `external_id TEXT`
nullable). Worth a live check in Phase 12 (a few real `GET /companies` responses from
production credentials) before finalizing the fuzzy-match-vs-externalId-lookup strategy.
3. **What confidence-scoring approach will Phase 12's fuzzy-name matching use** (e.g.
Levenshtein, token-set-ratio, a Postgres extension like `pg_trgm`)?
- What we know: `device_link_review`'s `match_confidences TEXT[]` stores confidence as text
labels, not a numeric score — precedent exists for either representation.
- What's unclear: Out of scope for Phase 10's research; noted here only so the
`pax8_company_match_review` table (created in Phase 10) doesn't need a column-type change
later — `TEXT[]` for confidences (matching `device_link_review`'s type) keeps that door open
for either a numeric-as-text or a labeled ("high"/"medium"/"low") representation.
- Recommendation: Use `TEXT[]` for `match_confidences` in this phase's migration, matching
`device_link_review` exactly — defer the actual scoring algorithm decision to Phase 12.
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Node global `fetch` | PAX8 token exchange + REST calls | Confirmed via existing `msgraph-client.ts` usage in this same codebase/runtime | Node 18+ (project targets Next.js 16, well above this floor) | — |
| PostgreSQL 16 | New migration (`pax8_companies` etc.) | Confirmed — project's primary datastore, already running | 16 | — |
| `PAX8_CLIENT_ID` / `PAX8_CLIENT_SECRET` env vars | `isPax8Configured()` returning true | **Not currently set** — grepped `.env`, no `PAX8_*` entries exist yet | — | Developer has credentials provisioned per SEED-002 but they are not yet in `.env`; this phase's success criteria (config check, throw-if-missing) work correctly either way, but success criterion #2 (live token exchange + companies call) requires the developer to add `PAX8_CLIENT_ID`/`PAX8_CLIENT_SECRET` to `.env` before that call can be executed/verified end-to-end |
**Missing dependencies with no fallback:**
- None blocking the code/schema work itself.
**Missing dependencies with fallback:**
- `PAX8_CLIENT_ID`/`PAX8_CLIENT_SECRET` not yet in `.env` — the planner should include a task (or
a `checkpoint:human-verify`-style step) for the developer to add these two vars before the
live auth-proof call (success criterion #2) can be verified. All other criteria (typed error
on missing creds, migration) don't require live credentials.
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | vitest 4.1.5 |
| Config file | `vitest.config.ts` (root) — `include: ['lib/**/*.test.ts']` |
| Quick run command | `npx vitest run lib/services/pax8-client.test.ts lib/services/pax8-factory.test.ts` |
| Full suite command | `npm test` |
No existing `*-client.test.ts` or `*-factory.test.ts` file exists anywhere in this codebase for
*any* integration (grepped the whole tree) — CLAUDE.md's stated test coverage
(`analyzer/**`, `rmm/**`, `b2/**`) does not include integration clients like `msgraph-client.ts`
or `veeam-client.ts` today. This phase would be the first to add tests for an integration client
if the planner chooses to (recommended, since PAX8-01/02's success criteria are directly
testable via mocked `fetch`, following the `vi.fn()` mock pattern used in
`lib/services/llm/call.test.ts`).
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| PAX8-02 | `isPax8Configured()` returns true only when both env vars set | unit | `npx vitest run lib/services/pax8-factory.test.ts` | ❌ Wave 0 |
| PAX8-02 | `getPax8Client()` throws typed/clear error when creds missing | unit | `npx vitest run lib/services/pax8-factory.test.ts` | ❌ Wave 0 |
| PAX8-01 | Token exchange sends correct body (`grant_type`, `audience`) and caches token by expiry | unit (mocked `fetch`) | `npx vitest run lib/services/pax8-client.test.ts` | ❌ Wave 0 |
| PAX8-01 | `listCompanies()`/equivalent auth-proof call attaches `Authorization: Bearer` header and parses `content`/`page` envelope | unit (mocked `fetch`) | `npx vitest run lib/services/pax8-client.test.ts` | ❌ Wave 0 |
| Migration success criterion (PAX8-01/02 supporting) | Migration applies cleanly with `IF NOT EXISTS`, idempotent on rerun | manual/smoke | `docker exec <pg-container> psql -f migrations/091_pax8_tables.sql` (or project's `scripts/apply-migrations` per CLAUDE.md) | N/A — no automated migration test harness exists in this codebase (none of the ~90 existing migrations have automated tests) |
### Sampling Rate
- **Per task commit:** `npx vitest run lib/services/pax8-client.test.ts lib/services/pax8-factory.test.ts` (once these exist)
- **Per wave merge:** `npm test` (full suite — cheap, this phase adds ~2 small test files)
- **Phase gate:** Full suite green before `/gsd:verify-work`; plus one manual live-credential
check (success criterion #2's actual token exchange against `api.pax8.com`) since mocked tests
alone can't prove the real PAX8 API contract matches what's mocked — this is the same
live-check gap every other integration in this codebase has (no existing integration client has
a "live API" test tier, by project convention/cost of hitting real vendor APIs in CI).
### Wave 0 Gaps
- [ ] `lib/services/pax8-client.test.ts` — covers PAX8-01 (token exchange, caching, auth-proof call)
- [ ] `lib/services/pax8-factory.test.ts` — covers PAX8-02 (`isPax8Configured()`, throw-if-missing)
- [ ] No new test framework/config needed — vitest is already configured project-wide and
`lib/**/*.test.ts` glob already picks up any new files in `lib/services/`.
## Security Domain
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|----------------|---------|-------------------|
| V2 Authentication | Partial — this is *outbound* service-to-service auth (Pulse → PAX8), not inbound user auth; N/A for Better Auth session concerns | OAuth2 client-credentials per PAX8's documented contract; no deviation |
| V3 Session Management | No | N/A — no user session involved, server-to-server token only |
| V4 Access Control | No | N/A — this phase adds no new user-facing routes/permissions |
| V5 Input Validation | Minimal | No user input flows into this phase's code paths (env vars are operator-controlled, not user input); standard `try/catch` + typed errors per CLAUDE.md convention is sufficient, no Zod needed here per CLAUDE.md's "don't add Zod for one field" guidance |
| V6 Cryptography | No — none hand-rolled | `PAX8_CLIENT_SECRET` handled exactly like every other integration secret in this codebase: read from `.env` via `process.env`, never logged, never echoed. **Note:** `.env` is committed to this repo per CLAUDE.md's explicit warning — flag to the developer that adding real `PAX8_CLIENT_ID`/`PAX8_CLIENT_SECRET` to the committed `.env` file (vs. an uncommitted `.env.local`) means the secret enters git history, consistent with how existing integrations are already handled in this repo, but worth the developer's explicit awareness per CLAUDE.md's "treat secrets as potentially real" instruction |
### Known Threat Patterns for this stack
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|----------------------|
| Client secret logged in error messages/stack traces | Information Disclosure | Follow existing pattern (`msgraph-factory.ts`, `veeam-factory.ts`): error messages name the *missing env var*, never echo the *value*; `console.log`/`console.error` calls in this phase must never interpolate `config.clientSecret` |
| Token cached indefinitely (never expires client-side) causing stale-auth failures downstream | Denial of Service (self-inflicted) | The 60-second expiry buffer pattern from `msgraph-client.ts` (`Date.now() < this.tokenExpiry - 60_000`) prevents using a token PAX8 has already invalidated server-side |
| SSRF via user-controlled PAX8 API path/query params | Tampering | Not applicable in this phase — no user input reaches PAX8 URLs; all paths/params are hardcoded or come from server-side pagination loops, not request bodies |
## Sources
### Primary (HIGH confidence)
- `lib/services/msgraph-client.ts`, `lib/services/msgraph-factory.ts` — direct codebase read, OAuth2 client-credentials template
- `lib/services/veeam-factory.ts` — direct codebase read, `is<Name>Configured()` + throw pattern
- `lib/services/appgate-client.ts`, `lib/services/appgate-factory.ts` — direct codebase read, most recently added integration (closest live template for factory shape and migration-comment style)
- `migrations/080_device_xref_company_id.sql` — direct codebase read, `device_link_review` table shape (model for `pax8_company_match_review`)
- `migrations/081_integration_settings.sql`, `migrations/089_appgate_tables.sql` — direct codebase read, migration style/idempotency conventions
- `lib/services/llm/call.test.ts` — direct codebase read, vitest mocking pattern for HTTP/SDK client tests
- `.planning/config.json` — direct read, confirms `nyquist_validation: true`, no `security_enforcement` key present (treated as enabled per protocol default)
### Secondary (MEDIUM confidence — WebFetch of official PAX8 docs, summarized by an LLM pass rather than read raw)
- [PAX8 Authentication docs](https://devx.pax8.com/docs/authentication) — token endpoint, request/response shape
- [Create a new Access Token (API reference)](https://devx.pax8.com/reference/createaccesstoken) — confirmed `audience: "https://api.pax8.com"` for partner endpoints
- [Using Pax8's APIs](https://devx.pax8.com/docs/public-api-details) — pagination envelope, rate limit (1000/min), auth header format
- [List Companies reference](https://devx.pax8.com/reference/findcompanies.md) — Company object schema, `externalId`/`website` fields
- [List Subscriptions reference](https://devx.pax8.com/reference/findsubscriptions.md) — Subscription object schema
- [List Products reference](https://devx.pax8.com/reference/findallproducts.md) — Product object schema
- [List Orders reference](https://devx.pax8.com/reference/findorders.md) + [Get Order by ID](https://devx.pax8.com/reference/findordersbyorderid.md) — confirmed absence of pricing/status fields on Order/LineItem
- [List Invoice Items reference](https://devx.pax8.com/reference/findpartnerinvoiceitems) — Invoice Item schema (pricing/status data source — see Pitfall 1)
- [Get Invoice by ID reference](https://devx.pax8.com/reference/getpartnerinvoice) — Invoice header schema
- [Mapping Pax8 Identifiers to Microsoft Graph API](https://devx.pax8.com/docs/microsoft-subscription-reconciliation) — `externalId` usage precedent for cross-system reconciliation
- [devx.pax8.com/llms.txt](https://devx.pax8.com/llms.txt) — endpoint index used to locate the above reference pages
### Tertiary (LOW confidence)
- [lwhitelock/Pax8API (GitHub, community PowerShell module)](https://github.com/lwhitelock/Pax8API) — used only to corroborate endpoint *existence* (`/orders`, `/invoices/{id}/items`), not field-level schema (module passes through untyped JSON)
## Metadata
**Confidence breakdown:**
- Standard stack (auth pattern, no new deps): HIGH — directly mirrors existing, working codebase patterns (`msgraph-client.ts`), zero new dependencies to evaluate
- Architecture (auth flow, pagination): MEDIUM-HIGH — official docs fetched and cross-checked across multiple pages (docs + reference), but rendered via LLM summarization of WebFetch output rather than raw OpenAPI JSON (login-gated); recommend one live API call during execution to convert to VERIFIED
- Entity schema fields (companies/subscriptions/products): MEDIUM — official reference pages returned full field lists consistently across two independent fetches
- Entity schema fields (orders/invoices pricing split): MEDIUM — this is the research's most important and most surprising finding; corroborated across three separate reference pages (orders, order-by-id, invoice-items) all agreeing on the same order/invoice field split, which raises confidence, but wasn't verified against a live API response
- Company identifier (`externalId`) semantics: LOW-MEDIUM — inferred from one cross-reference doc (Microsoft reconciliation guide) rather than the companies endpoint's own field description; flagged in Assumptions Log A3 for a live-data check in Phase 12
- Pitfalls: MEDIUM-HIGH — auth audience and content-type pitfalls are directly sourced from PAX8's own reference docs; rate-limit pitfall is documented but not this phase's concern
**Research date:** 2026-07-10
**Valid until:** 30 days (stable, unversioned public REST API with no observed active deprecations) — but treat the orders/invoices field-split finding (Pitfall 1) as needing a live-credential re-check regardless of date, since it's the one finding this research couldn't verify against a real API response.