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:
parent
90ed8ba893
commit
26f41a7011
4 changed files with 677 additions and 1 deletions
|
|
@ -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
|
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
|
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
|
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
|
**UI hint**: no
|
||||||
|
|
||||||
### Phase 11: Company, Catalog & Subscription Sync
|
### Phase 11: Company, Catalog & Subscription Sync
|
||||||
|
|
|
||||||
245
.planning/phases/10-pax8-client-auth-foundation/10-01-PLAN.md
Normal file
245
.planning/phases/10-pax8-client-auth-foundation/10-01-PLAN.md
Normal 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>
|
||||||
232
.planning/phases/10-pax8-client-auth-foundation/10-02-PLAN.md
Normal file
232
.planning/phases/10-pax8-client-auth-foundation/10-02-PLAN.md
Normal 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>
|
||||||
196
.planning/phases/10-pax8-client-auth-foundation/10-03-PLAN.md
Normal file
196
.planning/phases/10-pax8-client-auth-foundation/10-03-PLAN.md
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue