---
phase: 13-scheduler-admin-toggle
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
  - lib/services/integration-health.ts
  - app/api/pax8/sync/route.ts
autonomous: true
requirements: [PAX8-09]
must_haves:
  truths:
    - "PAX8 appears as a row (key 'pax8', name 'PAX8', category 'finance') in checkIntegrationHealth output, making it toggleable on /admin/integrations"
    - "A disabled PAX8 row flows through the existing applyDisableOverlay so its disabled state is reflected on /admin/integrations without any change to the overlay logic"
    - "POST /api/pax8/sync returns HTTP 403 when integration_settings.key='pax8' has disabled=true — no side door via the manual route while the toggle says off (D-02)"
    - "POST /api/pax8/sync behaves unchanged (starts sync / 409 if in progress) when PAX8 is not disabled"
  artifacts:
    - path: "lib/services/integration-health.ts"
      provides: "checkConfigOnly('pax8', ...) call site inside checkIntegrationHealth"
      contains: "checkConfigOnly('pax8'"
    - path: "app/api/pax8/sync/route.ts"
      provides: "403 disabled-gate at the top of POST"
      contains: "status: 403"
  key_links:
    - from: "app/api/pax8/sync/route.ts (POST)"
      to: "integration_settings"
      via: "SELECT disabled ... WHERE key = 'pax8'"
      pattern: "integration_settings WHERE key = 'pax8'"
    - from: "lib/services/integration-health.ts (checkIntegrationHealth)"
      to: "checkConfigOnly"
      via: "Promise.resolve in the Promise.all array"
      pattern: "checkConfigOnly\\('pax8'"
---

<objective>
Make PAX8 appear as a toggleable row on `/admin/integrations`, and make the manual
sync route refuse to run while PAX8 is disabled.

Purpose: Delivers the admin-surface half of PAX8-09 — the toggle row (SC#2) and the
"disable closes the side door" enforcement on the manual trigger (D-02, SC#3). No new
auth surface, no changes to the integration_settings CRUD route.
Output: One new `checkConfigOnly('pax8', ...)` call site and a 403 gate at the top of
`POST /api/pax8/sync`.
</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/13-scheduler-admin-toggle/13-CONTEXT.md
@.planning/phases/13-scheduler-admin-toggle/13-PATTERNS.md

<interfaces>
<!-- Contracts the executor needs. Do not re-derive from the codebase. -->

From lib/services/integration-health.ts:
  - checkConfigOnly(key: string, name: string, category: IntegrationHealth['category'], envVars: string[]): IntegrationHealth  (lines 238-252)
  - IntegrationHealth['category'] union (line 36): 'psa'|'rmm'|'docs'|'security'|'backup'|'network'|'identity'|'mdm'|'mail'|'finance'|'productivity'|'llm'  (no vendor/marketplace — use 'finance' to match qbo)
  - Existing call sites in checkIntegrationHealth's Promise.all array (lines 344-347): qbo ('finance') and appgate ('security') — copy this exact shape
  - getDbDisabledKeys (295-308) + applyDisableOverlay (310-319): operate generically on item.key — adding the pax8 call site is enough for the disable overlay to cover it. Do NOT modify these.

From lib/services/pax8-factory.ts (confirmed env var names):
  isPax8Configured() = Boolean(process.env.PAX8_CLIENT_ID && process.env.PAX8_CLIENT_SECRET)

From app/api/pax8/sync/route.ts:
  - line 3: `import postgresClient from '@/lib/services/postgres-client';`  (default import — reuse)
  - POST(req) lines 5-20: parses body, checks svc.isSyncInProgress() -> 409, fire-and-forget fullSync
  - Existing error shape in this file: `NextResponse.json({ error: '...' }, { status: 409 })`

DB toggle query shape:
  `SELECT disabled FROM integration_settings WHERE key = 'pax8'` -> read rows[0]?.disabled === true
  (no row => not disabled => sync proceeds — correct default)
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Register PAX8 as an integration-health row</name>
  <files>lib/services/integration-health.ts</files>
  <read_first>
    - lib/services/integration-health.ts (line 36 category union; lines 238-252 checkConfigOnly; lines 330-348 the Promise.all array with qbo/appgate call sites; lines 295-319 disable-overlay — read but do NOT modify)
    - lib/services/pax8-factory.ts (confirm PAX8_CLIENT_ID / PAX8_CLIENT_SECRET are the exact env vars)
    - .planning/phases/13-scheduler-admin-toggle/13-PATTERNS.md (integration-health section)
  </read_first>
  <action>
    Add exactly one entry to the `Promise.all([...])` array inside `checkIntegrationHealth()`, immediately after the appgate call site (line ~347): `Promise.resolve(checkConfigOnly('pax8', 'PAX8', 'finance', ['PAX8_CLIENT_ID', 'PAX8_CLIENT_SECRET']))`.

    Use category `'finance'` — it matches qbo (billing/subscription data) and is a valid member of the existing IntegrationHealth['category'] union (line 36), which has no vendor/marketplace option. Do NOT add a new category to the union.

    Do NOT modify checkConfigOnly, getDbDisabledKeys, or applyDisableOverlay — the new row automatically flows through the disable overlay because applyDisableOverlay keys off item.key generically. This single call site is the entirety of SC#2 (PAX8 becomes a toggleable row).
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - `npx tsc --noEmit --pretty` passes.
    - `grep -c "checkConfigOnly('pax8'" lib/services/integration-health.ts` returns 1.
    - The call uses name `'PAX8'`, category `'finance'`, and env vars `['PAX8_CLIENT_ID', 'PAX8_CLIENT_SECRET']` in that order.
    - `git diff lib/services/integration-health.ts` shows NO change to getDbDisabledKeys, applyDisableOverlay, checkConfigOnly, or the category union — only the one added array entry.
  </acceptance_criteria>
  <done>checkIntegrationHealth returns a pax8 row so PAX8 renders as a toggleable integration on /admin/integrations.</done>
</task>

<task type="auto">
  <name>Task 2: Gate POST /api/pax8/sync on the disabled toggle (403)</name>
  <files>app/api/pax8/sync/route.ts</files>
  <read_first>
    - app/api/pax8/sync/route.ts (full file, 54 lines — POST lines 5-20, GET 22-54, postgresClient default import line 3)
    - lib/services/integration-health.ts (getDbDisabledKeys lines 295-308 — the query shape to scope to WHERE key = 'pax8')
    - .planning/phases/13-scheduler-admin-toggle/13-PATTERNS.md (route section, exact target POST body)
  </read_first>
  <action>
    Per D-02, insert a disabled-check as the FIRST statement in `POST` — before `req.json()`, before `getPax8SyncService()`, and before the `isSyncInProgress()` 409 check. Query `postgresClient.query<{ disabled: boolean }>("SELECT disabled FROM integration_settings WHERE key = 'pax8'")` (postgresClient default import at line 3 — reuse, no new import; constant literal SQL, no interpolation). If `rows[0]?.disabled === true`, return `NextResponse.json({ error: 'PAX8 is disabled', message: 'PAX8 sync is disabled via /admin/integrations' }, { status: 403 })`.

    "Disabled means fully off — no side door via the manual route while the toggle says off" (D-02). When not disabled (or no row), the rest of POST is unchanged: 409 if isSyncInProgress(), else fire-and-forget fullSync(triggeredBy).

    Do NOT modify the `GET` handler. Do NOT add auth changes (out of scope — the route's existing auth posture is unchanged). Do NOT extract a shared helper with the scheduler check (D-01 — inline is intended).
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - `npx tsc --noEmit --pretty` passes.
    - `app/api/pax8/sync/route.ts` contains `status: 403` and the strings `'PAX8 is disabled'` and `integration_settings WHERE key = 'pax8'`.
    - The 403 check appears before the `isSyncInProgress()` line (verify by reading — the disabled query is the first statement in POST).
    - `git diff app/api/pax8/sync/route.ts` shows the GET handler is unchanged.
    - No new import statements were added (postgresClient already imported at line 3).
  </acceptance_criteria>
  <done>POST /api/pax8/sync returns 403 when PAX8 is disabled and otherwise behaves exactly as before.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| client → POST /api/pax8/sync | Authenticated caller triggers a background sync; must be denied while the operator toggle is off |
| /admin/integrations → integration_settings | Admin toggles PAX8 on/off; toggle state must govern the sync action, not just display |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-13-01 | Elevation of Privilege | POST /api/pax8/sync | mitigate | 403 disabled-gate as the first statement in POST closes the manual "side door" while PAX8 is disabled (D-02, Task 2) |
| T-13-04 | Tampering (SQL injection) | disabled-check query | mitigate | Query is a constant literal `WHERE key = 'pax8'` — no interpolation of request body or params |
| T-13-05 | Information Disclosure | 403 error message | accept | Message states only that PAX8 is disabled via /admin/integrations — no secrets, no internal detail leaked |
| T-13-SC | Tampering | npm/pip/cargo installs | accept | No package installs in this plan — both edits use existing dependencies |
</threat_model>

<verification>
- `npx tsc --noEmit --pretty` passes.
- checkIntegrationHealth includes a `checkConfigOnly('pax8', 'PAX8', 'finance', [...])` entry.
- POST /api/pax8/sync has a 403 disabled-gate as its first statement; GET unchanged.
- Live "row appears + 403 when disabled" behavior is proven in the Wave 2 verification plan (13-03).
</verification>

<success_criteria>
- PAX8 renders as a toggleable row on /admin/integrations (via the new checkConfigOnly call).
- Disabling PAX8 causes POST /api/pax8/sync to return 403.
- No changes to integration_settings CRUD, disable-overlay helpers, the GET handler, or auth.
</success_criteria>

<output>
Create `.planning/phases/13-scheduler-admin-toggle/13-02-SUMMARY.md` when done.
</output>
