# Phase 10: PAX8 Client & Auth Foundation - Pattern Map

**Mapped:** 2026-07-10
**Files analyzed:** 4 (2 optional test files also flagged, see below)
**Analogs found:** 4 / 4

## File Classification

| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|--------------------|------|-----------|-----------------|----------------|
| `lib/services/pax8-client.ts` | service (integration client) | request-response (OAuth2 client-credentials + REST) | `lib/services/msgraph-client.ts` | exact |
| `lib/services/pax8-factory.ts` | service (factory/singleton) | request-response (config check + lazy init) | `lib/services/msgraph-factory.ts` (secondary: `lib/services/appgate-factory.ts`) | exact |
| `lib/types/pax8.ts` | model (types barrel) | transform (API shape → typed interfaces) | `lib/types/appgate.ts` | exact |
| `migrations/091_pax8_tables.sql` | migration | CRUD (schema DDL) | `migrations/089_appgate_tables.sql` (table style) + `migrations/080_device_xref_company_id.sql` (review-queue shape) | exact |
| `lib/services/pax8-client.test.ts` (optional, RESEARCH-recommended) | test | request-response (mocked `fetch`) | `lib/services/llm/call.test.ts` (mocking pattern only — no existing `*-client.test.ts` precedent in this codebase) | role-match (no exact precedent exists) |
| `lib/services/pax8-factory.test.ts` (optional, RESEARCH-recommended) | test | request-response (env-var config check) | none — no `*-factory.test.ts` exists anywhere in the codebase | no analog |

## Pattern Assignments

### `lib/services/pax8-client.ts` (service, request-response)

**Analog:** `lib/services/msgraph-client.ts` (OAuth2 client-credentials shape), with two required deviations from RESEARCH.md's Pitfall 2/3 — PAX8's token body is JSON (not form-encoded) and requires an `audience` field.

**Imports/class shape pattern** (`lib/services/msgraph-client.ts` lines 55-68):
```typescript
export interface MsGraphClientConfig {
  tenantId: string;
  clientId: string;
  clientSecret: string;
}

export class MsGraphClient {
  private config: MsGraphClientConfig;
  private accessToken: string | null = null;
  private tokenExpiry: number = 0;

  constructor(config: MsGraphClientConfig) {
    this.config = config;
  }
```
For PAX8: `Pax8ClientConfig { clientId: string; clientSecret: string }` (no tenant concept). Type import convention should follow `appgate-client.ts` line 24-33 (`import type { ... } from '@/lib/types/appgate'`) — i.e. `pax8-client.ts` should `import type { Pax8Company, Pax8Subscription, ... } from '@/lib/types/pax8'` rather than declaring response interfaces inline, since a dedicated `lib/types/pax8.ts` file is already planned.

**Token exchange + expiry-aware cache pattern** (`lib/services/msgraph-client.ts` lines 70-98):
```typescript
private async getToken(): Promise<string> {
  if (this.accessToken && Date.now() < this.tokenExpiry - 60000) {
    return this.accessToken;
  }

  const url = `https://login.microsoftonline.com/${this.config.tenantId}/oauth2/v2.0/token`;
  const body = new URLSearchParams({
    grant_type: 'client_credentials',
    client_id: this.config.clientId,
    client_secret: this.config.clientSecret,
    scope: 'https://graph.microsoft.com/.default',
  });

  const res = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: body.toString(),
  });

  if (!res.ok) {
    const text = await res.text();
    throw new Error(`Graph 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!;
}
```
**Copy the structure (cache check, expiry math, error-on-!res.ok) but change the body encoding and add `audience`** per RESEARCH.md Pattern 1 / Pitfall 3:
```typescript
// PAX8 deviation — JSON body + audience field, NOT 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', // NOT api://provisioning — see Pitfall 2
  }),
});
```

**Authenticated fetch + retry-on-429 pattern** (`lib/services/msgraph-client.ts` lines 100-122):
```typescript
private async fetchJson<T>(path: string, retryCount = 0): Promise<T> {
  const token = await this.getToken();
  const res = await fetch(`https://graph.microsoft.com/v1.0${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(`Graph API error ${res.status} for ${path}: ${text}`);
  }

  return res.json();
}
```
Copy this shape verbatim for `pax8-client.ts`'s internal `fetchJson<T>()`, swapping the base URL to `https://api.pax8.com/v1` — the 429/`Retry-After` handling is directly reusable and matches RESEARCH.md's Pitfall 4 note (PAX8 rate limit is 1000/min account-wide; not exercised by this phase's single auth-proof call, but the retry scaffold costs nothing to include now).

**Paginated list-endpoint pattern** — no `msgraph-client.ts` equivalent uses PAX8's exact `{ content, page }` envelope (Graph uses `@odata.nextLink`), so this piece has no direct in-repo analog. Use RESEARCH.md's Pattern 3 code example directly:
```typescript
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 };
}
```
This phase only needs one auth-proof call (`listCompanies`) — do not build a generic pagination-looping utility (RESEARCH.md's "Don't Hand-Roll" table explicitly rejects this).

**Error handling pattern:** Every method in `msgraph-client.ts` follows `if (!res.ok) { const text = await res.text(); throw new Error(...) }` — no custom error classes, no try/catch wrapper inside the client itself (callers catch). Apply identically in `pax8-client.ts`. Never interpolate `config.clientSecret` into any thrown message or `console.log` (RESEARCH.md Security Domain — Information Disclosure row).

---

### `lib/services/pax8-factory.ts` (service, request-response)

**Analog:** `lib/services/msgraph-factory.ts` (primary — same 3-var config shape) and `lib/services/appgate-factory.ts` (secondary — most recently added integration, same JSDoc-header + reset-seam convention).

**Full pattern** (`lib/services/msgraph-factory.ts` lines 1-43, adapt var names/count):
```typescript
import { MsGraphClient, MsGraphClientConfig } from './msgraph-client';

let msGraphClientInstance: MsGraphClient | null = null;

export function isMsgraphConfigured(): boolean {
  return !!(
    process.env.MSGRAPH_CLIENT_ID &&
    process.env.MSGRAPH_CLIENT_SECRET &&
    process.env.MSGRAPH_TENANT_ID
  );
}

export function getMsgraphClient(): MsGraphClient {
  if (!msGraphClientInstance) {
    const config: MsGraphClientConfig = {
      tenantId: process.env.MSGRAPH_TENANT_ID || '',
      clientId: process.env.MSGRAPH_CLIENT_ID || '',
      clientSecret: process.env.MSGRAPH_CLIENT_SECRET || '',
    };

    if (!config.tenantId || !config.clientId || !config.clientSecret) {
      throw new Error(
        'Microsoft Graph credentials missing. Set MSGRAPH_CLIENT_ID, MSGRAPH_CLIENT_SECRET, and MSGRAPH_TENANT_ID.'
      );
    }

    msGraphClientInstance = new MsGraphClient(config);
    console.log('[MSGRAPH] Client initialized');
  }

  return msGraphClientInstance;
}

export function resetMsgraphClient(): void {
  msGraphClientInstance = null;
}
```
PAX8 only has 2 env vars (`PAX8_CLIENT_ID`, `PAX8_CLIENT_SECRET` — no tenant), so `appgate-factory.ts`'s tighter `Boolean(a && b)` early-return style (lines 19-42, reproduced below) is the better literal template since it's closer to PAX8's 2-var shape and uses the `_reset` naming convention RESEARCH.md's own code example (Pattern 2) already committed to:
```typescript
import { AppgateClient } from './appgate-client';

let _client: AppgateClient | null = null;

export function isAppgateConfigured(): boolean {
  return Boolean(
    process.env.APPGATE_URL &&
    process.env.APPGATE_USERNAME &&
    process.env.APPGATE_PASSWORD &&
    process.env.APPGATE_DEVICE_ID,
  );
}

export function getAppgateClient(): AppgateClient {
  if (_client) return _client;
  if (!isAppgateConfigured()) {
    throw new Error('AppGate is not configured — set APPGATE_URL, APPGATE_USERNAME, APPGATE_PASSWORD, APPGATE_DEVICE_ID');
  }
  _client = new AppgateClient({ /* ... */ });
  return _client;
}

// Test seam — reset the cached client (e.g. after rotating credentials).
export function _resetAppgateClient(): void {
  _client = null;
}
```
**Recommendation:** follow RESEARCH.md's own Pattern 2 code example verbatim (it already merges both templates correctly) — `isPax8Configured()`, `getPax8Client()` throwing `'PAX8 is not configured — set PAX8_CLIENT_ID and PAX8_CLIENT_SECRET'`, and `_resetPax8Client()`.

---

### `lib/types/pax8.ts` (model, transform)

**Analog:** `lib/types/appgate.ts` — same "types barrel per external integration" role, most recently added, uses `[key: string]: unknown` escape hatch for partially-typed vendor payloads.

**File header + section-comment convention** (`lib/types/appgate.ts` lines 1-10):
```typescript
/**
 * Type definitions for the AppGate SDP Controller REST API (v22.5) and the
 * shapes Pulse persists.  Source spec lives at
 * `https://wawnvaagp01.wulfconsulting.com:8443/api_specs.html`.
 *
 * Only the slices Pulse consumes are typed — the API exposes ~150 endpoints,
 * most of which are administrative writes Pulse never makes.
 */

// ─── API response shapes ──────────────────────────────────────────────────
```
Adapt to: `PAX8 REST API (v1)` header, cite `https://devx.pax8.com`, keep the `// ─── API response shapes ───` / `// ─── Sync orchestration ───` section-divider convention (lines 10, 115 of `appgate.ts`).

**Envelope + partial-typing pattern** (`lib/types/appgate.ts` lines 49-70, 109-113):
```typescript
export interface AppgateAppliance {
  id: string;
  name: string;
  // ...fully-typed known fields...
  [key: string]: unknown; // escape hatch for fields not yet modeled
}

export interface AppgateResultList<T> {
  range?: string;
  totalCount?: number;
  data: T[];
}
```
For PAX8, define the equivalent pagination envelope generic matching RESEARCH.md's confirmed shape:
```typescript
export interface Pax8PageEnvelope<T> {
  content: T[];
  page: { size: number; totalElements: number; totalPages: number; number: number };
}
```
Type the four entities (`Pax8Company`, `Pax8Subscription`, `Pax8Product`, and the Invoice/InvoiceItem-sourced `Pax8Order`/`Pax8OrderItem` — see RESEARCH.md Pitfall 1 on field-source naming) with known fields typed and `[key: string]: unknown` for the long tail, matching `AppgateAppliance`'s style. Do NOT type every documented PAX8 field exhaustively — `appgate.ts` deliberately types "only the slices Pulse consumes."

---

### `migrations/091_pax8_tables.sql` (migration, CRUD)

**Analogs:** `migrations/089_appgate_tables.sql` (table/index/comment style for the 4 primary entity tables) + `migrations/080_device_xref_company_id.sql` (`device_link_review` — explicit shape reference for `pax8_company_match_review` per CONTEXT.md's Claude's Discretion section).

**Header comment convention** (`migrations/089_appgate_tables.sql` lines 1-11):
```sql
-- AppGate SDP integration — Postgres schema.
--
-- Mirrors the slices of Appgate SDP Controller REST API v22.5 that Pulse
-- surfaces for the manager-on-the-go view:
--
--   • Active sessions (current snapshot)        -> appgate_active_sessions
--   • On-boarded devices (slowly changing list) -> appgate_devices
--   ...
```
Adapt to a similar bullet list mapping PAX8 entities → table names, and flag inline (per RESEARCH.md Pitfall 1) that `pax8_orders`/`pax8_order_items` columns are sourced from PAX8's Invoice/InvoiceItem objects, not the bare Order/LineItem objects.

**Primary entity table shape — audit columns + soft-delete + raw JSONB** (`migrations/089_appgate_tables.sql` lines 56-77, closest to a "typed columns + raw payload" table):
```sql
CREATE TABLE IF NOT EXISTS appgate_appliances (
    id                          UUID PRIMARY KEY,
    name                        TEXT NOT NULL,
    hostname                    TEXT,
    tags                        JSONB,
    roles                       JSONB,   -- {controller, gateway, logServer, ...} subset flags
    raw                         JSONB,   -- full payload — versioned schema changes
    created_at                  TIMESTAMPTZ,
    updated_at                  TIMESTAMPTZ,
    synced_at                   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    is_deleted                  BOOLEAN NOT NULL DEFAULT false,
    deleted_at                  TIMESTAMPTZ
);

CREATE INDEX IF NOT EXISTS idx_appgate_appliances_is_deleted ON appgate_appliances(is_deleted);
```
Use this as the template for `pax8_companies`, `pax8_subscriptions`, `pax8_products` (rename `raw` → `raw_payload` per D-03's explicit naming) — all get `synced_at`, `is_deleted`, `deleted_at` per CLAUDE.md's stated audit-column convention. Add `idx_*_is_deleted` indexes matching this pattern.

**Monetary + currency columns pattern** (`migrations/051_create_qbo_tables.sql` lines 14-33 — closest existing precedent for NUMERIC + currency on a financial entity, matching D-04 exactly):
```sql
CREATE TABLE IF NOT EXISTS qbo_invoices (
    id                  TEXT PRIMARY KEY,  -- QBO Id
    ...
    total_amt           NUMERIC(12,2),
    balance             NUMERIC(12,2),
    status              TEXT,              -- Open, Paid, Voided, etc.
    currency_code       TEXT DEFAULT 'USD',
    line_items          JSONB,
    ...
    synced_at           TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
D-04 locks `NUMERIC(12,2)` + `currency CHAR(3) DEFAULT 'USD'` (CHAR(3), not TEXT — a slightly stricter type than `qbo_invoices.currency_code TEXT` uses, but the same intent). Apply this to `pax8_orders.total`/`pax8_order_items.unit_price`/`line_total`, sourcing the actual field names from PAX8's Invoice/InvoiceItem schema per RESEARCH.md Pitfall 1 (`amountDue`, `price`, `subTotal`, invoice-level `status`), not the bare Order/LineItem fields which don't carry these.

**Header/line-item FK relationship pattern** — no existing Pulse migration models a strict header+lines pattern as cleanly as PAX8's own docs describe; RESEARCH.md's migration skeleton (Code Examples section, lines 478-515 of RESEARCH.md) is the most concrete starting point:
```sql
CREATE TABLE IF NOT EXISTS pax8_companies (
  id                        UUID PRIMARY KEY,
  name                      TEXT NOT NULL,
  external_id               TEXT,
  website                   TEXT,
  status                     TEXT,
  raw_payload                JSONB,
  synced_at                  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  is_deleted                 BOOLEAN NOT NULL DEFAULT false,
  deleted_at                 TIMESTAMPTZ
);
```
`pax8_order_items` should FK to `pax8_orders(id)` `ON DELETE CASCADE`, mirroring how `migrations/051_create_qbo_tables.sql`'s later tables (payments/credit memos, not shown above) and `device_link_review`'s `ON DELETE CASCADE` (below) both use cascade deletes for child rows.

**Review-queue table shape** (`migrations/080_device_xref_company_id.sql` lines 62-86 — copy near-verbatim per CONTEXT.md's explicit instruction):
```sql
CREATE TABLE IF NOT EXISTS device_link_review (
  id                       UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  device_external_id       BIGINT NOT NULL REFERENCES device_external_ids(id) ON DELETE CASCADE,
  candidate_ci_ids         BIGINT[] NOT NULL,
  match_confidences        TEXT[]   NOT NULL,
  detected_at              TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  resolved_at              TIMESTAMPTZ,
  resolved_by_user_id      TEXT NULL REFERENCES "user"(id) ON DELETE SET NULL,
  resolved_to_ci_id        BIGINT NULL REFERENCES configuration_items(id) ON DELETE SET NULL,
  resolution_note          TEXT
);

CREATE INDEX IF NOT EXISTS ix_device_link_review_unresolved
  ON device_link_review(detected_at DESC) WHERE resolved_at IS NULL;
CREATE INDEX IF NOT EXISTS ix_device_link_review_xref
  ON device_link_review(device_external_id);

CREATE UNIQUE INDEX IF NOT EXISTS uq_device_link_review_open_per_xref
  ON device_link_review(device_external_id) WHERE resolved_at IS NULL;

COMMENT ON TABLE device_link_review IS
  'Reconciler conflicts queue: one row per unlinked device_external_ids row that matches 2+ configuration_items. Admin picks the right CI; reconciler does not auto-merge.';
```
Map field-for-field per CONTEXT.md: `device_external_id` → `pax8_company_id` (FK to `pax8_companies(id) ON DELETE CASCADE`), `candidate_ci_ids BIGINT[]` → `candidate_company_ids BIGINT[]` (FK target: Autotask `companies.id`), `resolved_to_ci_id` → `resolved_to_company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL`. Keep `match_confidences TEXT[]` exactly as-is (RESEARCH.md Open Question 3 explicitly recommends matching this type rather than a numeric score, to keep the door open for either representation). Keep the three-index pattern (unresolved-queue index, xref lookup index, partial-unique "one open review per source row" index) and the `COMMENT ON TABLE` documentation habit — RESEARCH.md's own skeleton reproduces this table almost verbatim (lines 501-511 of RESEARCH.md), confirming this is the intended template.

---

## Shared Patterns

### OAuth2 Client-Credentials Token Cache
**Source:** `lib/services/msgraph-client.ts` lines 70-98
**Apply to:** `lib/services/pax8-client.ts` (with JSON-body + `audience` deviation per Pitfall 2/3 above)
```typescript
if (this.accessToken && Date.now() < this.tokenExpiry - 60000) {
  return this.accessToken;
}
// ...POST to token endpoint...
this.accessToken = data.access_token;
this.tokenExpiry = Date.now() + data.expires_in * 1000;
```

### Factory Singleton + `is<Name>Configured()` + Throw-If-Missing + Reset Seam
**Source:** `lib/services/msgraph-factory.ts` (full file) / `lib/services/appgate-factory.ts` (full file)
**Apply to:** `lib/services/pax8-factory.ts`
```typescript
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; }
```

### Error Handling — Never Interpolate Secrets
**Source:** `lib/services/msgraph-client.ts` (every method), `lib/services/appgate-factory.ts` line 31 (error names missing env vars, never values)
**Apply to:** All PAX8 files
```typescript
if (!res.ok) {
  const text = await res.text();
  throw new Error(`PAX8 API error ${res.status} for ${path}: ${text}`);
}
```
Never log/throw `config.clientSecret` itself — only name which env var is missing.

### Migration Audit Columns + Soft Delete
**Source:** `migrations/089_appgate_tables.sql` (all tables), `migrations/088_qbo_invoices_soft_delete.sql`, CLAUDE.md's stated convention
**Apply to:** All four `pax8_*` primary entity tables in `091_pax8_tables.sql`
```sql
synced_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_deleted   BOOLEAN NOT NULL DEFAULT false,
deleted_at   TIMESTAMPTZ
```
Plus a matching `CREATE INDEX IF NOT EXISTS idx_<table>_is_deleted ON <table>(is_deleted);` per table.

### Raw-Payload Safety Net (D-03)
**Source:** `migrations/089_appgate_tables.sql` line 69 (`raw JSONB — full payload`), CONTEXT.md D-03 citing `itglue-search.ts` precedent
**Apply to:** `pax8_orders`, `pax8_order_items` (locked by D-03); recommended default for `pax8_companies`/`pax8_subscriptions`/`pax8_products` per Claude's Discretion section
```sql
raw_payload JSONB,  -- full API payload — safety net for fields not yet modeled
```

## No Analog Found

| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `lib/services/pax8-client.test.ts` | test | request-response (mocked `fetch`) | No `*-client.test.ts` exists for any integration client in this codebase (confirmed by RESEARCH.md's grep of the whole tree). `lib/services/llm/call.test.ts` is the closest available *mocking-style* reference (vi.fn() fake + call-count/body assertions) but tests a different layer (Anthropic SDK wrapper, not raw `fetch`). Planner should write this test from RESEARCH.md's Validation Architecture section rather than an in-repo analog. |
| `lib/services/pax8-factory.test.ts` | test | request-response (env-var check) | No `*-factory.test.ts` exists anywhere in the codebase. No analog to copy from; use plain vitest `describe`/`it` with `process.env` mutation + `_resetPax8Client()` between tests. |

## Metadata

**Analog search scope:** `lib/services/` (integration clients + factories), `lib/types/`, `migrations/`
**Files scanned:** `lib/services/msgraph-client.ts`, `lib/services/msgraph-factory.ts`, `lib/services/veeam-factory.ts`, `lib/services/appgate-client.ts`, `lib/services/appgate-factory.ts`, `lib/types/appgate.ts`, `migrations/080_device_xref_company_id.sql`, `migrations/089_appgate_tables.sql`, `migrations/088_qbo_invoices_soft_delete.sql`, `migrations/051_create_qbo_tables.sql`, `lib/services/llm/call.test.ts`
**Pattern extraction date:** 2026-07-10
