10 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 13-scheduler-admin-toggle | 02 | execute | 1 |
|
true |
|
|
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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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.mdFrom 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)
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).
npx tsc --noEmit --pretty
- `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.
checkIntegrationHealth returns a pax8 row so PAX8 renders as a toggleable integration on /admin/integrations.
Task 2: Gate POST /api/pax8/sync on the disabled toggle (403)
app/api/pax8/sync/route.ts
- 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)
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).
npx tsc --noEmit --pretty
- `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).
POST /api/pax8/sync returns 403 when PAX8 is disabled and otherwise behaves exactly as before.
<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> |
<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>