---
phase: 10-pax8-client-auth-foundation
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - lib/types/pax8.ts
  - lib/services/pax8-client.ts
  - lib/services/pax8-factory.ts
  - lib/services/pax8-client.test.ts
  - lib/services/pax8-factory.test.ts
autonomous: true
requirements: [PAX8-01, PAX8-02]

must_haves:
  truths:
    - "isPax8Configured() returns true only when PAX8_CLIENT_ID and PAX8_CLIENT_SECRET are BOTH set, and false when either is missing"
    - "getPax8Client() throws a clear error naming PAX8_CLIENT_ID and PAX8_CLIENT_SECRET when credentials are absent (no silent failure, no crash)"
    - "The token request POSTs a JSON body (Content-Type application/json) with grant_type=client_credentials and audience=https://api.pax8.com"
    - "A valid cached token is reused without a second network call until it nears expiry (Date.now() < tokenExpiry - 60000)"
    - "listCompanies() attaches Authorization: Bearer <token> and parses the { content, page } envelope"
    - "No PAX8 client secret value is ever interpolated into a thrown error, console log, or test assertion"
  artifacts:
    - path: "lib/types/pax8.ts"
      provides: "Typed PAX8 entity interfaces + Pax8PageEnvelope<T>"
      exports: ["Pax8PageEnvelope", "Pax8Company", "Pax8Subscription", "Pax8Product", "Pax8Order", "Pax8OrderItem"]
    - path: "lib/services/pax8-client.ts"
      provides: "Pax8Client class: getToken(), fetchJson<T>(), listCompanies()"
      exports: ["Pax8Client", "Pax8ClientConfig"]
    - path: "lib/services/pax8-factory.ts"
      provides: "isPax8Configured(), getPax8Client(), _resetPax8Client()"
      exports: ["isPax8Configured", "getPax8Client", "_resetPax8Client"]
    - path: "lib/services/pax8-client.test.ts"
      provides: "Mocked-fetch unit tests for token exchange, caching, auth-proof call"
    - path: "lib/services/pax8-factory.test.ts"
      provides: "Unit tests for config presence + throw-if-missing + singleton reset"
  key_links:
    - from: "lib/services/pax8-factory.ts"
      to: "lib/services/pax8-client.ts"
      via: "import { Pax8Client, Pax8ClientConfig }"
      pattern: "import.*Pax8Client.*from './pax8-client'"
    - from: "lib/services/pax8-client.ts"
      to: "lib/types/pax8.ts"
      via: "import type { Pax8Company, ... }"
      pattern: "import type.*from '@/lib/types/pax8'"
---

<objective>
Build the PAX8 OAuth2 client-credentials integration: a typed entity barrel,
a `Pax8Client` that exchanges credentials for a bearer token and performs a
read-only auth-proof call, and a factory exposing `isPax8Configured()` /
`getPax8Client()` / `_resetPax8Client()` — all matching the existing
`msgraph-client.ts` / `appgate-factory.ts` integration pattern.

Purpose: Satisfies PAX8-01 (OAuth2 client-credentials auth against
`api.pax8.com/v1`) and PAX8-02 (`isPax8Configured()` factory helper). This is
the auth half of the Phase 10 foundation, proving the handshake in code before
Phase 11 builds any sync logic on top of it.

Output: `lib/types/pax8.ts`, `lib/services/pax8-client.ts`,
`lib/services/pax8-factory.ts`, and their two vitest files.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/10-pax8-client-auth-foundation/10-CONTEXT.md
@.planning/phases/10-pax8-client-auth-foundation/10-RESEARCH.md
@.planning/phases/10-pax8-client-auth-foundation/10-PATTERNS.md

# Analog source files (10-PATTERNS.md quotes the exact line ranges to copy)
@lib/services/msgraph-client.ts
@lib/services/msgraph-factory.ts
@lib/services/appgate-factory.ts
@lib/types/appgate.ts
@lib/services/llm/call.test.ts

<interfaces>
<!-- vitest is configured with globals: false (vitest.config.ts). Every test file
     MUST import from 'vitest': describe, it, expect, vi, beforeEach.
     include glob is lib/**/*.test.ts — new files under lib/services/ are auto-picked. -->
<!-- Token contract (10-RESEARCH.md Pattern 1 / Pitfall 2 & 3):
     POST https://api.pax8.com/v1/token
     Content-Type: application/json  (NOT x-www-form-urlencoded like msgraph)
     body JSON: { grant_type, client_id, client_secret, audience: "https://api.pax8.com" }
     response: { access_token, token_type, expires_in, ... }
     List envelope: { content: T[], page: { size, totalElements, totalPages, number } } -->
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: PAX8 typed entity barrel (contracts first)</name>
  <files>lib/types/pax8.ts</files>
  <read_first>
    - lib/types/appgate.ts (header-comment + section-divider + `[key: string]: unknown` escape-hatch convention — the exact analog per 10-PATTERNS.md)
    - .planning/phases/10-pax8-client-auth-foundation/10-RESEARCH.md (Pitfall 1: Order/LineItem lack pricing; model Pax8Order/Pax8OrderItem fields on Invoice/InvoiceItem — total/status/currencyCode on order; price/subTotal/quantity on item. Also: altVendorSku on Product is deprecated — omit it)
  </read_first>
  <action>
    Create `lib/types/pax8.ts` as a types-only barrel (no runtime code), following
    `lib/types/appgate.ts` conventions: a JSDoc header naming the PAX8 REST API (v1)
    and citing `https://devx.pax8.com`, then a `// ─── API response shapes ───` divider.
    Export a generic `Pax8PageEnvelope<T>` with `content: T[]` and
    `page: { size: number; totalElements: number; totalPages: number; number: number }`.
    Export five entity interfaces with camelCase fields (API shape) and a trailing
    `[key: string]: unknown` escape hatch on each: `Pax8Company` (id, name, externalId,
    website, status, city, stateOrProvince, postalCode, country), `Pax8Subscription`
    (id, companyId, productId, quantity, billingTerm, status, startDate),
    `Pax8Product` (id, sku, vendorSku, name, category — do NOT include the deprecated
    altVendorSku), `Pax8Order` (id, companyId, orderDate, total, status, currencyCode —
    modeled on the PAX8 Invoice object per RESEARCH Pitfall 1), `Pax8OrderItem`
    (id, orderId, productId, quantity, price, subTotal, currencyCode — modeled on the
    PAX8 Invoice Item object). Do NOT declare `Pax8ClientConfig` here — it lives in
    pax8-client.ts. Type only the slices Pulse consumes; rely on the escape hatch for
    the long tail, exactly as appgate.ts does.
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c "^export interface\|^export type" lib/types/pax8.ts` shows at least 6 exports
    - File exports `Pax8PageEnvelope`, `Pax8Company`, `Pax8Subscription`, `Pax8Product`, `Pax8Order`, `Pax8OrderItem` (verified by grep for each identifier)
    - Every entity interface contains a `[key: string]: unknown` line
    - No occurrence of `altVendorSku` anywhere in the file
    - `npx tsc --noEmit --pretty` exits 0
  </acceptance_criteria>
  <done>lib/types/pax8.ts exists with the 6 exports, escape hatches present, tsc clean.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Pax8Client (token exchange + auth-proof call) with mocked-fetch tests</name>
  <files>lib/services/pax8-client.ts, lib/services/pax8-client.test.ts</files>
  <read_first>
    - lib/services/msgraph-client.ts (lines ~55-122: config interface, private accessToken/tokenExpiry fields, getToken() 60_000-buffer cache, fetchJson<T>() 429/Retry-After retry, `if (!res.ok) throw` error convention)
    - lib/services/appgate-client.ts (import-type-from-@/lib/types convention)
    - lib/services/llm/call.test.ts (vitest mocking style: vi.fn() fakes, call-count/body assertions — the only mocking analog in this codebase)
    - lib/types/pax8.ts (created in Task 1)
    - .planning/phases/10-pax8-client-auth-foundation/10-PATTERNS.md (the copy-this / change-this deltas vs msgraph-client.ts)
  </read_first>
  <behavior>
    - getToken() POSTs to https://api.pax8.com/v1/token with header Content-Type application/json and a JSON.stringify body containing grant_type='client_credentials', client_id, client_secret, and audience='https://api.pax8.com'
    - getToken() returns the cached token WITHOUT a second fetch when Date.now() < tokenExpiry - 60000 (assert fetch called exactly once across two getToken calls)
    - getToken() throws an Error including the HTTP status when the token response is not ok; the thrown message must NOT contain the client secret value
    - listCompanies() sends header Authorization: Bearer <token> and returns the parsed { content, page } object
  </behavior>
  <action>
    Write `lib/services/pax8-client.test.ts` FIRST (RED): import { describe, it, expect, vi, beforeEach } from 'vitest'; stub the global fetch with vi.fn() (via vi.stubGlobal('fetch', ...) or assigning globalThis.fetch), returning a fake Response ({ ok: true, json: async () => ({ access_token: 'tok', expires_in: 86400 }) } for the token call, and a { content: [...], page: {...} } payload for the companies call). Cover all four behaviors above, including a not-ok token response asserting the throw contains the status and never the secret.
    Then write `lib/services/pax8-client.ts` (GREEN): export `interface Pax8ClientConfig { clientId: string; clientSecret: string }`; export `class Pax8Client` with private `config`, private `accessToken: string | null = null`, private `tokenExpiry = 0`. Implement private async getToken() copying msgraph-client.ts's cache-check/expiry-math/error-on-!res.ok structure but with the JSON body + audience deviation (Content-Type application/json, JSON.stringify({ grant_type, client_id, client_secret, audience: 'https://api.pax8.com' })). Implement private async fetchJson<T>(path, retryCount = 0) copying msgraph-client.ts's 429/Retry-After retry loop verbatim, base URL https://api.pax8.com/v1, Authorization: Bearer header. Implement public async listCompanies(page = 0, size = 10): Promise<Pax8PageEnvelope<Pax8Company>> calling GET /companies?page=&size= through fetchJson. `import type { Pax8Company, Pax8PageEnvelope } from '@/lib/types/pax8'`. Error strings follow the `PAX8 API error ${res.status} for ${path}: ${text}` shape — never interpolate config.clientSecret into any throw or console call. Add a one-line comment noting Phase 11/12 will extend fetchJson with 429-aware backoff for the account-wide 1000/min limit (RESEARCH Pitfall 4).
  </action>
  <verify>
    <automated>npx vitest run lib/services/pax8-client.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - Test asserts the token POST body parses to an object whose `audience` === 'https://api.pax8.com' and whose header Content-Type is 'application/json'
    - Test asserts fetch is called exactly once when getToken() is invoked twice within the cache window
    - Test asserts the companies request carries an Authorization header starting with 'Bearer '
    - Test asserts a not-ok token response throws and the thrown message contains the status code but NOT the mocked secret string
    - `npx vitest run lib/services/pax8-client.test.ts` reports all tests passing
    - `grep -n "clientSecret" lib/services/pax8-client.ts` shows it used only inside the JSON.stringify token body — never inside a throw/Error/console argument
    - `npx tsc --noEmit --pretty` exits 0
  </acceptance_criteria>
  <done>pax8-client.ts implements the token cache + auth-proof call; all client tests green; secret never leaks into errors/logs.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 3: pax8-factory (isPax8Configured / getPax8Client / _resetPax8Client) with tests</name>
  <files>lib/services/pax8-factory.ts, lib/services/pax8-factory.test.ts</files>
  <read_first>
    - lib/services/appgate-factory.ts (tightest 2-var-style template: Boolean(a && b) early-return, throw-if-missing naming the env vars, `_reset` seam — per 10-PATTERNS.md this is the closest literal analog to PAX8's 2-var shape)
    - lib/services/msgraph-factory.ts (secondary: `console.log('[NAME] Client initialized')` on init)
    - lib/services/pax8-client.ts (created in Task 2 — Pax8Client + Pax8ClientConfig imports)
    - .planning/phases/10-pax8-client-auth-foundation/10-RESEARCH.md (Pattern 2 — the exact factory code to follow verbatim)
  </read_first>
  <behavior>
    - isPax8Configured() returns true only when process.env.PAX8_CLIENT_ID AND process.env.PAX8_CLIENT_SECRET are both truthy; returns false when either is unset
    - getPax8Client() throws Error with message exactly 'PAX8 is not configured — set PAX8_CLIENT_ID and PAX8_CLIENT_SECRET' when not configured
    - getPax8Client() returns the same cached Pax8Client instance on repeated calls (singleton)
    - _resetPax8Client() clears the cached singleton so a subsequent getPax8Client() rebuilds it
  </behavior>
  <action>
    Write `lib/services/pax8-factory.test.ts` FIRST (RED): import { describe, it, expect, beforeEach } from 'vitest'; in beforeEach delete process.env.PAX8_CLIENT_ID / PAX8_CLIENT_SECRET and call _resetPax8Client() to isolate cases. Cover: both-set → isPax8Configured() true and getPax8Client() returns an instance; either-missing → isPax8Configured() false and getPax8Client() throws the exact message; two getPax8Client() calls return the identical reference; after _resetPax8Client() the reference differs.
    Then write `lib/services/pax8-factory.ts` (GREEN) following RESEARCH Pattern 2 verbatim: module-level `let _client: Pax8Client | null = null`; `export function isPax8Configured(): boolean` returning `Boolean(process.env.PAX8_CLIENT_ID && process.env.PAX8_CLIENT_SECRET)`; `export function getPax8Client(): Pax8Client` that returns `_client` if set, throws `new Error('PAX8 is not configured — set PAX8_CLIENT_ID and PAX8_CLIENT_SECRET')` when !isPax8Configured(), else constructs `new Pax8Client({ clientId: process.env.PAX8_CLIENT_ID!, clientSecret: process.env.PAX8_CLIENT_SECRET! })`, logs `console.log('[PAX8] Client initialized')` (never the secret), caches and returns it; `export function _resetPax8Client(): void { _client = null; }`.
  </action>
  <verify>
    <automated>npx vitest run lib/services/pax8-factory.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - Test asserts isPax8Configured() is false when only PAX8_CLIENT_ID is set, false when only PAX8_CLIENT_SECRET is set, true when both are set
    - Test asserts getPax8Client() throws with message equal to 'PAX8 is not configured — set PAX8_CLIENT_ID and PAX8_CLIENT_SECRET'
    - Test asserts two consecutive getPax8Client() calls return the same object reference, and that _resetPax8Client() forces a new one
    - `npx vitest run lib/services/pax8-factory.test.ts` reports all tests passing
    - `grep -n "clientSecret\|CLIENT_SECRET" lib/services/pax8-factory.ts` shows the secret env var only read into the config object — never in a console/throw argument
    - `npm test` (full suite) exits 0
  </acceptance_criteria>
  <done>pax8-factory.ts exports the three functions; all factory tests green; full suite green.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Pulse service → PAX8 API (outbound) | Server-side OAuth2 client-credentials handshake; the client secret crosses only from `process.env` into the HTTPS request body |
| operator env → process.env | `PAX8_CLIENT_ID` / `PAX8_CLIENT_SECRET` are operator-controlled config, not user input |

No inbound user-facing route, no browser code path, and no external package install in this plan (native `fetch` + existing `pg` only) — the supply-chain (`T-*-SC`) checkpoint is not triggered.

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-10-01 | Information Disclosure | pax8-client.ts / pax8-factory.ts error + log paths | mitigate | Error messages name the missing env var only; `config.clientSecret` is never interpolated into any throw, `console.log`, or test assertion. Enforced by grep acceptance criteria in Tasks 2 and 3 |
| T-10-02 | Information Disclosure | in-memory token cache | accept | Token held only in a private class field, never persisted or logged; server-side singleton only. Matches the established msgraph-client.ts pattern |
| T-10-03 | Spoofing / Tampering (MITM) | PAX8 token + API endpoints | mitigate | Base URLs hardcoded to `https://api.pax8.com` (TLS enforced, no `http://` fallback, no env-overridable host) |
| T-10-04 | Elevation of Privilege | OAuth2 `audience` value | mitigate | `audience` hardcoded to `https://api.pax8.com` (partner/reseller scope) per RESEARCH Pitfall 2 — prevents accidentally minting a wrong-scoped provisioning token; wrong audience surfaces as a 403 on the Plan 03 live-proof call |
| T-10-05 | Denial of Service (self-inflicted) | stale token reuse | mitigate | 60-second expiry buffer (`Date.now() < tokenExpiry - 60000`) prevents presenting a token PAX8 has already invalidated |

No HIGH-severity threat blocks this plan. All secret-handling threats are mitigated by the never-log/never-throw-secret discipline and the hardcoded TLS host.
</threat_model>

<verification>
- `npx tsc --noEmit --pretty` exits 0
- `npx vitest run lib/services/pax8-client.test.ts lib/services/pax8-factory.test.ts` — all green
- `npm test` — full suite green (this plan adds ~2 small test files, no regressions)
- `grep -rn "clientSecret" lib/services/pax8-client.ts lib/services/pax8-factory.ts` confirms no secret in any throw/console argument
</verification>

<success_criteria>
- PAX8-02: `isPax8Configured()` returns true only when both env vars are set; `getPax8Client()` throws a clear typed error naming both env vars when they are missing (ROADMAP SC#1, SC#3) — proven by pax8-factory.test.ts
- PAX8-01: The client performs the OAuth2 client-credentials token exchange with the correct JSON body + `audience`, caches by expiry, and issues an authenticated `/companies` read (ROADMAP SC#2 code path) — proven by pax8-client.test.ts (the LIVE proof against api.pax8.com is Plan 03)
</success_criteria>

<output>
Create `.planning/phases/10-pax8-client-auth-foundation/10-01-SUMMARY.md` when done.
</output>
