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
|
||||
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
|
||||
|
|
|
|||
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