docs(10): create phase plan

3 plans across 2 waves for PAX8 Client & Auth Foundation (PAX8-01, PAX8-02).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHRgZqkzBHBbAbc3KHneuR
This commit is contained in:
lorentz 2026-07-10 13:16:23 -04:00
parent 90ed8ba893
commit 26f41a7011
4 changed files with 677 additions and 1 deletions

View file

@ -254,7 +254,10 @@ render, including the manual-resolution workflow for flagged companies.
2. `getPax8Client()` performs an OAuth2 client-credentials token exchange against `api.pax8.com/v1` and successfully calls a read-only endpoint (e.g., list companies) using the resulting bearer token
3. Calling the client with missing/invalid credentials throws a clear, typed error rather than failing silently or crashing the process — matching the existing `is<Name>Configured()` + throw-if-missing pattern used by other integrations
4. A new numbered migration creates the PAX8 tables (companies, subscriptions, products/catalog, orders, and a company-match/review table) using `IF NOT EXISTS`, ready for Phase 11+ to populate
**Plans**: TBD
**Plans**: 3 plans
- [ ] 10-01-PLAN.md — PAX8 types + OAuth2 client (token exchange, audience, cache) + factory (isPax8Configured/getPax8Client) + mocked tests (PAX8-01, PAX8-02)
- [ ] 10-02-PLAN.md — migrations/091_pax8_tables.sql (6 PAX8 tables, IF NOT EXISTS) + apply to dev DB (PAX8-01, PAX8-02)
- [ ] 10-03-PLAN.md — verify-pax8-auth.ts live auth-proof (SC#2) + CLAUDE.md/INTEGRATIONS.md docs (PAX8-01, PAX8-02)
**UI hint**: no
### Phase 11: Company, Catalog & Subscription Sync

View file

@ -0,0 +1,245 @@
---
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>

View file

@ -0,0 +1,232 @@
---
phase: 10-pax8-client-auth-foundation
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- migrations/091_pax8_tables.sql
autonomous: true
requirements: [PAX8-01, PAX8-02]
must_haves:
truths:
- "Migration 091 creates all six PAX8 tables: pax8_companies, pax8_subscriptions, pax8_products, pax8_orders, pax8_order_items, pax8_company_match_review"
- "Every table is created with IF NOT EXISTS so the migration is idempotent on re-apply (no error on second run)"
- "pax8_order_items.order_id is a hard FK to pax8_orders(id) ON DELETE CASCADE (header/line relationship)"
- "pax8_company_match_review carries candidate_company_ids BIGINT[], match_confidences TEXT[], a hard FK to pax8_companies(id) ON DELETE CASCADE, and a nullable resolved_to_company_id BIGINT REFERENCES companies(id)"
- "Monetary columns are NUMERIC(12,2) with a companion currency CHAR(3) DEFAULT 'USD' (D-04); orders/order_items pricing+status columns carry PAX8 Invoice/InvoiceItem semantics, flagged inline (RESEARCH Pitfall 1)"
- "Each of the four primary entity tables carries raw_payload JSONB + synced_at + is_deleted + deleted_at audit columns and an idx_<table>_is_deleted index"
artifacts:
- path: "migrations/091_pax8_tables.sql"
provides: "PAX8 schema DDL (6 tables, indexes, review-queue comment)"
contains: "CREATE TABLE IF NOT EXISTS pax8_companies"
key_links:
- from: "pax8_order_items.order_id"
to: "pax8_orders(id)"
via: "FK ON DELETE CASCADE"
pattern: "REFERENCES pax8_orders\\(id\\) ON DELETE CASCADE"
- from: "pax8_company_match_review.resolved_to_company_id"
to: "companies(id)"
via: "FK ON DELETE SET NULL"
pattern: "REFERENCES companies\\(id\\) ON DELETE SET NULL"
- from: "pax8_company_match_review.pax8_company_id"
to: "pax8_companies(id)"
via: "FK ON DELETE CASCADE"
pattern: "REFERENCES pax8_companies\\(id\\) ON DELETE CASCADE"
---
<objective>
Create the numbered SQL migration that lays down the full PAX8 schema — four
entity tables (companies, subscriptions, products, orders + order_items) plus a
company-match/review queue — all with `IF NOT EXISTS`, ready for Phase 11+ sync
services to populate. Then apply it to the existing dev DB (whose volume already
booted, so migrations do not auto-run) and confirm idempotency.
Purpose: Delivers Phase 10 Success Criterion #4 (the migration exists and creates
the PAX8 tables). Schema-only by design — no sync logic, no matching logic, no
indexes beyond what the `device_link_review` precedent demonstrates. The schema
here is the foundation Phases 11-12 (PAX8-03..06) will populate.
Output: `migrations/091_pax8_tables.sql`, applied and verified against the dev DB.
</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 migrations (10-PATTERNS.md quotes the exact line ranges to model)
@migrations/089_appgate_tables.sql
@migrations/080_device_xref_company_id.sql
@migrations/051_create_qbo_tables.sql
<interfaces>
<!-- FK target types verified in the codebase:
companies.id -> BIGINT PRIMARY KEY (migrations/001_initial_schema.sql)
"user".id -> TEXT PRIMARY KEY (migrations/012_create_auth_tables.sql)
device_link_review shape (migrations/080) is the verbatim template for
pax8_company_match_review — map device_external_id->pax8_company_id,
candidate_ci_ids->candidate_company_ids, resolved_to_ci_id->resolved_to_company_id.
Apply mechanism (existing dev volume, migrations do NOT auto-run):
bash scripts/apply-migrations.sh 091_pax8_tables.sql
which runs: docker exec -i pulse-postgres psql -U pulse_user -d pulse_autotask < <file> -->
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Author migrations/091_pax8_tables.sql</name>
<files>migrations/091_pax8_tables.sql</files>
<read_first>
- migrations/089_appgate_tables.sql (header-comment bullet-list style; primary-entity table shape with `raw JSONB` + `synced_at`/`is_deleted`/`deleted_at` audit columns + `idx_<table>_is_deleted` index — the template for the four pax8_* entity tables)
- migrations/080_device_xref_company_id.sql (device_link_review, lines ~62-86: copy near-verbatim for pax8_company_match_review — gen_random_uuid() default, BIGINT[]/TEXT[] arrays, three indexes including the partial-unique "one open review per source row", COMMENT ON TABLE)
- migrations/051_create_qbo_tables.sql (qbo_invoices, lines ~14-33: NUMERIC(12,2) + status + currency precedent for the monetary columns)
- migrations/001_initial_schema.sql (companies table: id is BIGINT — the FK target type for candidate_company_ids and resolved_to_company_id)
- .planning/phases/10-pax8-client-auth-foundation/10-CONTEXT.md (D-01..D-04 locked decisions)
- .planning/phases/10-pax8-client-auth-foundation/10-RESEARCH.md (Pitfall 1: model orders/order_items pricing+status columns on Invoice/InvoiceItem; Open Question 3: keep match_confidences as TEXT[])
</read_first>
<action>
Create `migrations/091_pax8_tables.sql`. Open with a header comment (089-style bullet
list) mapping PAX8 entities to tables, and an inline note that `pax8_orders`/
`pax8_order_items` column names carry Invoice/InvoiceItem semantics (total, status,
unit price) per RESEARCH Pitfall 1 — table names stay orders per D-01, but Phase 12
sync will source these from PAX8's `/invoices` resource, not `/orders`. Then create
six tables, all `CREATE TABLE IF NOT EXISTS`:
1. `pax8_companies`: id UUID PRIMARY KEY (PAX8's own company id), name TEXT NOT NULL,
external_id TEXT (partner-writable — leave nullable, do not assume name is the only
join key), website TEXT, status TEXT, 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.
2. `pax8_products`: id UUID PRIMARY KEY, sku TEXT, vendor_sku TEXT, name TEXT,
category TEXT, raw_payload JSONB, + the same three audit columns. (No
altVendorSku — deprecated.) Do NOT add any "referenced only" constraint (keep the
full-vs-lazy catalog decision open for Phase 11 per CONTEXT Claude's Discretion).
3. `pax8_subscriptions`: id UUID PRIMARY KEY, pax8_company_id UUID (plain indexed
column, NOT a hard FK — sync insert order across companies/subscriptions is not
guaranteed; add a `-- soft ref` comment), product_id TEXT, quantity INTEGER (seat
count), billing_term TEXT, status TEXT, start_date TIMESTAMPTZ, raw_payload JSONB,
+ the three audit columns.
4. `pax8_orders` (header — Invoice-shaped): id UUID PRIMARY KEY, pax8_company_id UUID
(soft ref, indexed), order_date TIMESTAMPTZ, total NUMERIC(12,2), status TEXT,
currency CHAR(3) NOT NULL DEFAULT 'USD', raw_payload JSONB, + the three audit columns.
5. `pax8_order_items` (line — InvoiceItem-shaped): id UUID PRIMARY KEY, order_id UUID
NOT NULL REFERENCES pax8_orders(id) ON DELETE CASCADE, product_id TEXT,
quantity INTEGER, unit_price NUMERIC(12,2), line_total NUMERIC(12,2),
currency CHAR(3) NOT NULL DEFAULT 'USD', raw_payload JSONB, + the three audit columns.
6. `pax8_company_match_review` (copy device_link_review field-for-field): 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,
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.
Indexes: one `CREATE INDEX IF NOT EXISTS idx_<table>_is_deleted ON <table>(is_deleted)`
per primary entity table (companies, products, subscriptions, orders, order_items);
plus indexes on pax8_subscriptions(pax8_company_id), pax8_orders(pax8_company_id),
pax8_order_items(order_id). For the review table, mirror device_link_review's three
indexes: `ix_pax8_company_match_review_unresolved ON (...)(detected_at DESC) WHERE
resolved_at IS NULL`, `ix_pax8_company_match_review_pax8_company ON (pax8_company_id)`,
and a partial-unique `uq_pax8_company_match_review_open ON (pax8_company_id) WHERE
resolved_at IS NULL`. Add a `COMMENT ON TABLE pax8_company_match_review` describing it
as the PAX8->Autotask company-match conflicts queue populated by Phase 12 (admin
resolves; matcher does not auto-merge). Never edit any committed migration.
</action>
<verify>
<automated>test $(grep -v '^--' migrations/091_pax8_tables.sql | grep -c "CREATE TABLE IF NOT EXISTS pax8_") -eq 6</automated>
</verify>
<acceptance_criteria>
- The grep-filtered count of `CREATE TABLE IF NOT EXISTS pax8_` equals 6
- `grep -q "REFERENCES pax8_orders(id) ON DELETE CASCADE" migrations/091_pax8_tables.sql` succeeds (order_items -> orders)
- `grep -q "candidate_company_ids BIGINT\[\] NOT NULL"` and `grep -q "match_confidences TEXT\[\] NOT NULL"` both succeed
- `grep -q "resolved_to_company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL"` succeeds
- `grep -q 'resolved_by_user_id TEXT REFERENCES "user"(id) ON DELETE SET NULL'` succeeds
- `grep -c "NUMERIC(12,2)"` is at least 3 (total, unit_price, line_total) and `grep -c "CHAR(3)"` is at least 2
- `grep -c "raw_payload JSONB"` is at least 4 (all primary entity tables)
- No occurrence of `altVendorSku`
</acceptance_criteria>
<done>migrations/091_pax8_tables.sql exists with all six tables, correct FK types, audit columns, monetary+currency columns, and the review-queue indexes/comment.</done>
</task>
<task type="auto">
<name>Task 2: Apply migration 091 to the dev DB and prove idempotency</name>
<files>migrations/091_pax8_tables.sql</files>
<read_first>
- scripts/apply-migrations.sh (single-migration mode: `bash scripts/apply-migrations.sh 091_pax8_tables.sql` runs `docker exec -i pulse-postgres psql -U pulse_user -d pulse_autotask < migrations/091_pax8_tables.sql`)
- CLAUDE.md ("Watch out for" / Database sections: Postgres applies migrations on first volume boot only — existing dev volume needs manual apply)
- migrations/091_pax8_tables.sql (the file created in Task 1)
</read_first>
<action>
Confirm the pulse-postgres container is running (`docker ps | grep pulse-postgres`).
Apply the migration with `bash scripts/apply-migrations.sh 091_pax8_tables.sql`. Then
list the created tables with `docker exec pulse-postgres psql -U pulse_user -d
pulse_autotask -c "\dt pax8_*"`. Re-run `bash scripts/apply-migrations.sh
091_pax8_tables.sql` a second time to prove idempotency (IF NOT EXISTS must produce no
error and exit 0). If the pulse-postgres container is NOT running/reachable in this
environment, do NOT fail the task: record in the SUMMARY the exact apply command above
as a pending developer step, and treat Task 1's grep gate as the completion proof —
the .sql file is the deliverable; live application is verification.
</action>
<verify>
<automated>docker exec pulse-postgres psql -U pulse_user -d pulse_autotask -tAc "SELECT count(*) FROM information_schema.tables WHERE table_name LIKE 'pax8\_%'" 2>/dev/null || echo "SKIP: pulse-postgres unavailable — apply is a documented developer step"</automated>
</verify>
<acceptance_criteria>
- When pulse-postgres is reachable: the table count for `pax8\_%` returns 6, and a second `bash scripts/apply-migrations.sh 091_pax8_tables.sql` exits 0 with no error (idempotent)
- When pulse-postgres is NOT reachable: the SUMMARY records the exact apply command as a pending developer step, and Task 1's grep gate stands as the completion proof
- The committed `.sql` file is unchanged by the apply step (application does not edit the migration)
</acceptance_criteria>
<done>Six pax8_* tables exist in the dev DB (or the apply command is documented as a pending developer step when the container is unreachable); re-apply is idempotent.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| migration DDL -> Postgres | Static, developer-authored DDL applied by an operator; no runtime user input reaches this SQL |
This plan installs no external packages and exposes no route — the supply-chain (`T-*-SC`) checkpoint is not triggered.
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-10-06 | Tampering (SQL injection) | migrations/091_pax8_tables.sql | accept | The migration is static DDL with no interpolated or dynamic values; no user/request data flows into it. Injection is not reachable |
| T-10-07 | Denial of Service | applying to the live dev DB | mitigate | All statements use IF NOT EXISTS; no DROP/ALTER of existing tables, no data mutation. Re-apply is a proven no-op (Task 2 idempotency check). Existing data is untouched |
| T-10-08 | Information Disclosure | raw_payload JSONB columns | accept | Columns are empty in this phase (schema only). When Phase 11+ populates them they hold PAX8 business data (subscriptions/costs), already inside the same trusted Postgres as Autotask data — no new exposure surface introduced here |
No HIGH-severity threat blocks this plan.
</threat_model>
<verification>
- `test $(grep -v '^--' migrations/091_pax8_tables.sql | grep -c "CREATE TABLE IF NOT EXISTS pax8_") -eq 6` passes
- Where docker is available: `\dt pax8_*` lists 6 tables; second apply exits 0 (idempotent)
- The migration touches no committed file other than the new `091_pax8_tables.sql`
</verification>
<success_criteria>
- PAX8 Phase-10 SC#4: a new numbered migration (`091_pax8_tables.sql`) creates the PAX8 companies, subscriptions, products, orders, order_items, and company-match/review tables using `IF NOT EXISTS`, ready for Phase 11+ to populate
- Orders/order_items columns are modeled with Invoice/InvoiceItem semantics (RESEARCH Pitfall 1) so `total`/`status`/`unit_price` are populatable by Phase 12; inline comment flags the `/invoices` sync source for Phase 12 confirmation
</success_criteria>
<output>
Create `.planning/phases/10-pax8-client-auth-foundation/10-02-SUMMARY.md` when done.
</output>

View file

@ -0,0 +1,196 @@
---
phase: 10-pax8-client-auth-foundation
plan: 03
type: execute
wave: 2
depends_on: ["10-01"]
files_modified:
- scripts/verify-pax8-auth.ts
- CLAUDE.md
- INTEGRATIONS.md
autonomous: false
requirements: [PAX8-01, PAX8-02]
user_setup:
- service: pax8
why: "Live OAuth2 token exchange + read-only companies call (Phase 10 SC#2) cannot run until the developer-provisioned PAX8 credentials are present"
env_vars:
- name: PAX8_CLIENT_ID
source: "PAX8 developer portal (devx.pax8.com) — provisioned client ID; add to .env.local (gitignored)"
- name: PAX8_CLIENT_SECRET
source: "PAX8 developer portal (devx.pax8.com) — provisioned client secret; add to .env.local (gitignored)"
must_haves:
truths:
- "A live token exchange against api.pax8.com/v1/token succeeds and a read-only /companies call returns a content array (Phase 10 SC#2, VERIFIED not just mocked)"
- "PAX8 appears in the CLAUDE.md External integrations table with the PAX8_* env-var prefix"
- "INTEGRATIONS.md documents PAX8: base URL, OAuth2 client-credentials + audience, the two env vars, and the isPax8Configured()/getPax8Client() entry points"
- "The verify script prints only company counts/status — never the token or the client secret"
artifacts:
- path: "scripts/verify-pax8-auth.ts"
provides: "One-off live auth-proof script loading .env.local and calling getPax8Client().listCompanies()"
- path: "CLAUDE.md"
provides: "PAX8 row in the External integrations table"
- path: "INTEGRATIONS.md"
provides: "PAX8 integration reference section"
key_links:
- from: "scripts/verify-pax8-auth.ts"
to: "lib/services/pax8-factory.ts"
via: "import { getPax8Client }"
pattern: "getPax8Client"
---
<objective>
Close out Phase 10 by (a) documenting the new PAX8 integration in the two
canonical docs the codebase keeps for integrations, and (b) proving the auth
handshake LIVE against the real PAX8 API — the one thing the mocked unit tests
in Plan 01 cannot prove. The live proof is gated on the developer adding their
provisioned PAX8 credentials, so this plan pauses for a human verification step.
Purpose: Satisfies Phase 10 Success Criterion #2 end-to-end (real token exchange
+ real read-only endpoint call) and records the PAX8_* env-var convention per
CONTEXT.md's canonical-refs instruction to add PAX8 to CLAUDE.md and INTEGRATIONS.md
once the client exists.
Output: `scripts/verify-pax8-auth.ts`, updated `CLAUDE.md` + `INTEGRATIONS.md`,
and a developer-confirmed live auth-proof.
</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
# Client built in Plan 01 (this plan depends on it existing)
@lib/services/pax8-factory.ts
@lib/services/pax8-client.ts
# Doc targets + script env-loading analog
@CLAUDE.md
@scripts/list-rmm-sites.ts
<interfaces>
<!-- Script env-loading pattern (scripts/list-rmm-sites.ts): scripts are plain .ts that
load the gitignored .env.local themselves via dotenv:
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve('/opt/stacks/pulse/.env.local') });
THEN import the factory. Run the same way other scripts/*.ts are run in this repo.
SECURITY FACT (verified): .gitignore line 34 is `.env*` and `git ls-files` shows NO
env file tracked — PAX8 secrets added to .env.local do NOT enter git history.
This supersedes RESEARCH's committed-.env concern. -->
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Write the live auth-proof script + document PAX8 in CLAUDE.md and INTEGRATIONS.md</name>
<files>scripts/verify-pax8-auth.ts, CLAUDE.md, INTEGRATIONS.md</files>
<read_first>
- scripts/list-rmm-sites.ts (exact env-loading + main().catch(process.exit) script shape to mirror)
- lib/services/pax8-factory.ts (getPax8Client entry point — created in Plan 01)
- lib/services/pax8-client.ts (listCompanies signature — created in Plan 01)
- CLAUDE.md (the "External integrations" markdown table listing each service and its env prefix — add a PAX8 row matching that exact column format)
- INTEGRATIONS.md IF IT EXISTS at repo root (match its existing per-integration section format); if absent, skip the INTEGRATIONS.md edit and note that in the SUMMARY (do not create a stub)
</read_first>
<action>
Create `scripts/verify-pax8-auth.ts` mirroring scripts/list-rmm-sites.ts: first
`import { config } from 'dotenv'` and `config({ path: resolve('/opt/stacks/pulse/.env.local') })`,
then import `getPax8Client` from `../lib/services/pax8-factory`. In an async main(), call
`getPax8Client().listCompanies(0, 1)`, then log ONLY a success summary — e.g. the count of
the returned `content` array and the `page.totalElements` value — and NEVER log the access
token, the client secret, or full company records. Wrap in `main().catch((err) => { console.error(err); process.exit(1); })`. Add a top-of-file comment: run with the same TS runner
used for other scripts/*.ts (e.g. `npx tsx scripts/verify-pax8-auth.ts`), requires
PAX8_CLIENT_ID and PAX8_CLIENT_SECRET in .env.local.
Then edit `CLAUDE.md`: add a row to the External integrations table for `PAX8` with env
prefix `PAX8_*`, matching the existing table's column layout. If `INTEGRATIONS.md` exists,
add a PAX8 section documenting: base URL `https://api.pax8.com/v1`, OAuth2 client-credentials
auth with `audience: https://api.pax8.com`, the two env vars (`PAX8_CLIENT_ID`,
`PAX8_CLIENT_SECRET`, stored in `.env.local`), and the `isPax8Configured()` / `getPax8Client()`
entry points in `lib/services/pax8-factory.ts` — matching the section format of the other
integrations already documented there.
</action>
<verify>
<automated>test -f scripts/verify-pax8-auth.ts && grep -q "getPax8Client" scripts/verify-pax8-auth.ts && grep -q "PAX8" CLAUDE.md && npx tsc --noEmit --pretty</automated>
</verify>
<acceptance_criteria>
- `scripts/verify-pax8-auth.ts` exists, loads `.env.local` via dotenv, and calls `getPax8Client().listCompanies(...)`
- `grep -n "access_token\|accessToken\|clientSecret\|CLIENT_SECRET" scripts/verify-pax8-auth.ts` returns no line that logs a secret/token (the script logs only counts/status)
- `CLAUDE.md` External integrations table contains a `PAX8` row with the `PAX8_*` prefix
- If INTEGRATIONS.md exists: it contains a PAX8 section naming both env vars and the `audience` value; if it does not exist, the SUMMARY records that it was skipped
- `npx tsc --noEmit --pretty` exits 0
</acceptance_criteria>
<done>Verify script written (secret-safe), CLAUDE.md has the PAX8 row, INTEGRATIONS.md documents PAX8 (or skip noted), tsc clean.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Live PAX8 auth-proof (developer adds credentials)</name>
<action>PAUSE for the developer. This is a human-verify checkpoint: the developer adds PAX8_CLIENT_ID/PAX8_CLIENT_SECRET to the gitignored .env.local and runs scripts/verify-pax8-auth.ts to prove the live token exchange + read-only /companies call succeed (Phase 10 SC#2). Do not auto-approve — resume only on the developer signal below.</action>
<what-built>
A live auth-proof script (`scripts/verify-pax8-auth.ts`) that runs the real PAX8 OAuth2
token exchange and a read-only `GET /companies` call through the Plan 01 client. Mocked
unit tests already prove the code shape; this confirms the real PAX8 API contract matches.
</what-built>
<how-to-verify>
1. Add your provisioned PAX8 credentials to `.env.local` (gitignored — verified `.env*` is
in .gitignore and no env file is tracked, so this does NOT enter git history):
PAX8_CLIENT_ID=... PAX8_CLIENT_SECRET=...
2. Run the script the same way you run other `scripts/*.ts` in this repo, e.g.:
npx tsx scripts/verify-pax8-auth.ts
3. Expected: it prints a success summary (a company count / totalElements) and exits 0 —
proving the token exchange returned a usable bearer token AND the `/companies` read
succeeded with it.
4. Failure signals to report back: a 200 token followed by a 403 on `/companies` means the
`audience` is wrong (RESEARCH Pitfall 2); a 400/415 on the token POST means the JSON
body/Content-Type is wrong (Pitfall 3); "PAX8 is not configured" means the env vars are
not being loaded from `.env.local`.
Security note: your PAX8_CLIENT_SECRET lives only in the gitignored `.env.local`; the script
never prints it or the token.
</how-to-verify>
<resume-signal>Type "approved" once the script prints a company count and exits 0, or paste the error output to triage.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| operator -> .env.local | Developer places real PAX8 credentials into the gitignored `.env.local` |
| Pulse script -> PAX8 API (outbound) | Live OAuth2 handshake + read-only companies call over HTTPS |
This plan installs no external packages (dotenv already present, used by existing scripts). Supply-chain (`T-*-SC`) checkpoint not triggered.
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-10-09 | Information Disclosure | PAX8 secret in git history | mitigate | Credentials go into `.env.local`, which `.gitignore` covers via `.env*` (line 34); `git ls-files` confirms no env file is tracked. Checkpoint instructions explicitly direct the secret to `.env.local`, NOT the (untracked) `.env`. Supersedes RESEARCH's committed-.env concern |
| T-10-10 | Information Disclosure | verify script stdout | mitigate | Script logs only company counts / `page.totalElements` and success status — never the access token, never the client secret. Enforced by grep acceptance criterion in Task 1 |
| T-10-11 | Elevation of Privilege | wrong OAuth2 audience surfaces at runtime | mitigate | The live call is the detector: a 200 token then 403 on `/companies` flags a wrong `audience` before Phase 11 builds sync on top of it. Checkpoint step 4 documents this signal |
No HIGH-severity threat blocks this plan. The one credential-handling threat (T-10-09) is mitigated by the verified gitignore coverage, so no blocking security halt is required.
</threat_model>
<verification>
- Task 1 automated gate passes (script exists, imports getPax8Client, CLAUDE.md updated, tsc clean)
- `grep -n "access_token\|clientSecret\|CLIENT_SECRET" scripts/verify-pax8-auth.ts` shows no secret/token logging
- Human checkpoint: `npx tsx scripts/verify-pax8-auth.ts` prints a company count and exits 0 (Phase 10 SC#2 LIVE)
</verification>
<success_criteria>
- PAX8-01 (SC#2, live): `getPax8Client()` performs a real OAuth2 client-credentials token exchange against `api.pax8.com/v1` and successfully calls a read-only endpoint (list companies) with the resulting bearer token — confirmed by the developer running the verify script
- PAX8 is documented in CLAUDE.md's External integrations table and (if present) INTEGRATIONS.md, per CONTEXT.md canonical-refs
</success_criteria>
<output>
Create `.planning/phases/10-pax8-client-auth-foundation/10-03-SUMMARY.md` when done.
</output>