diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index b9f31a6..ed6214d 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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 `isConfigured()` + 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 diff --git a/.planning/phases/10-pax8-client-auth-foundation/10-01-PLAN.md b/.planning/phases/10-pax8-client-auth-foundation/10-01-PLAN.md new file mode 100644 index 0000000..914831b --- /dev/null +++ b/.planning/phases/10-pax8-client-auth-foundation/10-01-PLAN.md @@ -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 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" + exports: ["Pax8PageEnvelope", "Pax8Company", "Pax8Subscription", "Pax8Product", "Pax8Order", "Pax8OrderItem"] + - path: "lib/services/pax8-client.ts" + provides: "Pax8Client class: getToken(), fetchJson(), 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'" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + + + + + + + + Task 1: PAX8 typed entity barrel (contracts first) + lib/types/pax8.ts + + - 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) + + + 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` 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. + + + npx tsc --noEmit --pretty + + + - `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 + + lib/types/pax8.ts exists with the 6 exports, escape hatches present, tsc clean. + + + + Task 2: Pax8Client (token exchange + auth-proof call) with mocked-fetch tests + lib/services/pax8-client.ts, lib/services/pax8-client.test.ts + + - lib/services/msgraph-client.ts (lines ~55-122: config interface, private accessToken/tokenExpiry fields, getToken() 60_000-buffer cache, fetchJson() 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) + + + - 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 and returns the parsed { content, page } object + + + 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(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> 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). + + + npx vitest run lib/services/pax8-client.test.ts + + + - 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 + + pax8-client.ts implements the token cache + auth-proof call; all client tests green; secret never leaks into errors/logs. + + + + Task 3: pax8-factory (isPax8Configured / getPax8Client / _resetPax8Client) with tests + lib/services/pax8-factory.ts, lib/services/pax8-factory.test.ts + + - 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) + + + - 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 + + + 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; }`. + + + npx vitest run lib/services/pax8-factory.test.ts + + + - 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 + + pax8-factory.ts exports the three functions; all factory tests green; full suite green. + + + + + +## 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. + + + +- `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 + + + +- 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) + + + +Create `.planning/phases/10-pax8-client-auth-foundation/10-01-SUMMARY.md` when done. + diff --git a/.planning/phases/10-pax8-client-auth-foundation/10-02-PLAN.md b/.planning/phases/10-pax8-client-auth-foundation/10-02-PLAN.md new file mode 100644 index 0000000..eb0a953 --- /dev/null +++ b/.planning/phases/10-pax8-client-auth-foundation/10-02-PLAN.md @@ -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__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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + + + + + + + Task 1: Author migrations/091_pax8_tables.sql + migrations/091_pax8_tables.sql + + - 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_
_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[]) + + + 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_
_is_deleted ON
(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. + + + test $(grep -v '^--' migrations/091_pax8_tables.sql | grep -c "CREATE TABLE IF NOT EXISTS pax8_") -eq 6 + + + - 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` + + migrations/091_pax8_tables.sql exists with all six tables, correct FK types, audit columns, monetary+currency columns, and the review-queue indexes/comment. + + + + Task 2: Apply migration 091 to the dev DB and prove idempotency + migrations/091_pax8_tables.sql + + - 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) + + + 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. + + + 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" + + + - 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) + + 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. + + + + + +## 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. + + + +- `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` + + + +- 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 + + + +Create `.planning/phases/10-pax8-client-auth-foundation/10-02-SUMMARY.md` when done. + diff --git a/.planning/phases/10-pax8-client-auth-foundation/10-03-PLAN.md b/.planning/phases/10-pax8-client-auth-foundation/10-03-PLAN.md new file mode 100644 index 0000000..ac5a423 --- /dev/null +++ b/.planning/phases/10-pax8-client-auth-foundation/10-03-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + + + + + + + Task 1: Write the live auth-proof script + document PAX8 in CLAUDE.md and INTEGRATIONS.md + scripts/verify-pax8-auth.ts, CLAUDE.md, INTEGRATIONS.md + + - 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) + + + 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. + + + test -f scripts/verify-pax8-auth.ts && grep -q "getPax8Client" scripts/verify-pax8-auth.ts && grep -q "PAX8" CLAUDE.md && npx tsc --noEmit --pretty + + + - `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 + + Verify script written (secret-safe), CLAUDE.md has the PAX8 row, INTEGRATIONS.md documents PAX8 (or skip noted), tsc clean. + + + + Task 2: Live PAX8 auth-proof (developer adds credentials) + 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. + + 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. + + + 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. + + Type "approved" once the script prints a company count and exits 0, or paste the error output to triage. + + + + + +## 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. + + + +- 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) + + + +- 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 + + + +Create `.planning/phases/10-pax8-client-auth-foundation/10-03-SUMMARY.md` when done. +