docs(13): create phase plan
This commit is contained in:
parent
c4f825bac4
commit
424c7c85fb
4 changed files with 516 additions and 2 deletions
|
|
@ -301,7 +301,10 @@ render, including the manual-resolution workflow for flagged companies.
|
|||
2. PAX8 appears as a toggleable row on `/admin/integrations`, backed by the `integration_settings` table like every other integration
|
||||
3. Disabling PAX8 from that UI stops future scheduled sync runs (respecting the existing health-cache window, or immediately per the PATCH-clears-cache convention) and records `disabled_by`, `disabled_at`, and an optional `disabled_reason`
|
||||
4. Re-enabling PAX8 resumes scheduled sync at the next cron tick with no code deploy or container restart required
|
||||
**Plans**: TBD
|
||||
**Plans**: 3 plans
|
||||
- [ ] 13-01-PLAN.md — Migration 096 pax8-daily seed + dual-guarded scheduler branch + CLAUDE.md precedent note (PAX8-07, PAX8-09)
|
||||
- [ ] 13-02-PLAN.md — checkConfigOnly('pax8') admin-integrations row + POST /api/pax8/sync 403 disabled-gate (PAX8-09)
|
||||
- [ ] 13-03-PLAN.md — Live verification checkpoint of Phase 13 SC#1-4 (PAX8-07, PAX8-09)
|
||||
**UI hint**: no
|
||||
|
||||
### Phase 14: /pax8 UI Surface
|
||||
|
|
@ -337,7 +340,7 @@ Phases execute in numeric order. v1.0 (Phases 1-9.1) shipped 2026-07-10. v2.0 ph
|
|||
| 10. PAX8 Client & Auth Foundation | v2.0 | 3/3 | Complete | 2026-07-10 |
|
||||
| 11. Company, Catalog & Subscription Sync | v2.0 | 3/3 | Complete | 2026-07-11 |
|
||||
| 12. Orders/Invoices & Company Matching | v2.0 | 5/5 | Complete | 2026-07-11 |
|
||||
| 13. Scheduler & Admin Toggle | v2.0 | 0/TBD | Not started | - |
|
||||
| 13. Scheduler & Admin Toggle | v2.0 | 0/3 | Not started | - |
|
||||
| 14. /pax8 UI Surface | v2.0 | 0/TBD | Not started | - |
|
||||
|
||||
---
|
||||
|
|
|
|||
222
.planning/phases/13-scheduler-admin-toggle/13-01-PLAN.md
Normal file
222
.planning/phases/13-scheduler-admin-toggle/13-01-PLAN.md
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
---
|
||||
phase: 13-scheduler-admin-toggle
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- migrations/096_pax8_daily_schedule.sql
|
||||
- lib/services/sync-scheduler.ts
|
||||
- CLAUDE.md
|
||||
autonomous: true
|
||||
requirements: [PAX8-07, PAX8-09]
|
||||
must_haves:
|
||||
truths:
|
||||
- "A pax8-daily row exists in sync_schedules (cron 0 4 * * *, is_enabled false by default)"
|
||||
- "The scheduler's executeScheduledSync dispatches sync_type 'pax8-daily' to getPax8SyncService().fullSync('scheduled')"
|
||||
- "When integration_settings.key='pax8' has disabled=true, the pax8-daily branch skips fullSync and logs a distinct message"
|
||||
- "When PAX8 env credentials are absent, the pax8-daily branch skips fullSync and logs a distinct 'not configured' message"
|
||||
artifacts:
|
||||
- path: "migrations/096_pax8_daily_schedule.sql"
|
||||
provides: "Idempotent seed of the pax8-daily sync_schedules row"
|
||||
contains: "pax8-daily"
|
||||
- path: "lib/services/sync-scheduler.ts"
|
||||
provides: "pax8-daily dispatch branch + sync_type union member"
|
||||
contains: "pax8-daily"
|
||||
key_links:
|
||||
- from: "lib/services/sync-scheduler.ts (pax8-daily branch)"
|
||||
to: "getPax8SyncService().fullSync"
|
||||
via: "lazy dynamic import + await call with 'scheduled'"
|
||||
pattern: "getPax8SyncService\\(\\)\\.fullSync\\('scheduled'\\)"
|
||||
- from: "lib/services/sync-scheduler.ts (pax8-daily branch)"
|
||||
to: "integration_settings"
|
||||
via: "SELECT disabled ... WHERE key = 'pax8'"
|
||||
pattern: "integration_settings WHERE key = 'pax8'"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Wire the already-complete `Pax8SyncService.fullSync()` (built in Phases 11-12) into the
|
||||
daily cron scheduler, and make a disabled PAX8 toggle actually stop scheduled runs.
|
||||
|
||||
Purpose: Delivers PAX8-07 (daily scheduled sync) and the scheduler-side half of PAX8-09
|
||||
(disable enforcement). PAX8 becomes the first Pulse integration where the DB toggle gates
|
||||
an *action* (a scheduled run), not just health-check display.
|
||||
Output: A seeded `pax8-daily` schedule row, a dual-guarded dispatch branch in
|
||||
`executeScheduledSync`, and a CLAUDE.md note recording the new gating precedent.
|
||||
</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/pax8-sync-service.ts:
|
||||
isSyncInProgress(): boolean
|
||||
async fullSync(triggeredBy = 'manual'): Promise<Pax8SyncResult> // call with 'scheduled'
|
||||
getPax8SyncService(): Pax8SyncService // factory export
|
||||
|
||||
From lib/services/pax8-factory.ts:
|
||||
isPax8Configured(): boolean // true only when PAX8_CLIENT_ID && PAX8_CLIENT_SECRET set
|
||||
|
||||
From lib/services/sync-scheduler.ts:
|
||||
- line 8: `import { postgresClient } from './postgres-client';` (already imported — reuse, no new import)
|
||||
- line 25: the sync_type union that must be extended with 'pax8-daily'
|
||||
- lines 445-457: the appgate-sessions/appgate-daily branch = direct template for guard style
|
||||
- createDefaultSchedules() / defaultSchedules array (~line 168-310): DO NOT add pax8 here — virgin-table only
|
||||
|
||||
DB toggle query shape (from integration-health.ts getDbDisabledKeys, lines 295-308):
|
||||
`SELECT disabled FROM integration_settings WHERE key = 'pax8'` (single-key form)
|
||||
Read result as: rows[0]?.disabled === true (no row => not disabled => runs — correct default)
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Seed the pax8-daily schedule via migration 096</name>
|
||||
<files>migrations/096_pax8_daily_schedule.sql</files>
|
||||
<read_first>
|
||||
- migrations/089_appgate_tables.sql (lines 136-150 — the exact INSERT ... SELECT ... WHERE NOT EXISTS precedent to mirror)
|
||||
- migrations/090_ticket_reconcile_schedule.sql (the alternate ON CONFLICT style — do NOT use; 089's WHERE NOT EXISTS is the chosen precedent per D-discretion note)
|
||||
- scripts/apply-migrations.sh (how migrations are applied to an existing dev DB)
|
||||
- .planning/phases/13-scheduler-admin-toggle/13-PATTERNS.md (migration section, exact target SQL)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `migrations/096_pax8_daily_schedule.sql` (096 is the next number — highest existing is 095_pax8_order_items_partner_cost_numeric.sql). Add a header comment explaining this covers existing installs because sync-scheduler.createDefaultSchedules() only seeds a virgin sync_schedules table, and that sync_schedules has no unique constraint on name so the seed is guarded with NOT EXISTS.
|
||||
|
||||
Write a single `INSERT INTO sync_schedules (id, name, description, cron_expression, sync_type, is_enabled) SELECT ... WHERE NOT EXISTS (SELECT 1 FROM sync_schedules WHERE name = 'PAX8 Daily Sync')` with these exact literal values:
|
||||
- id: 'pax8-daily'
|
||||
- name: 'PAX8 Daily Sync'
|
||||
- description: 'Full PAX8 sync — companies, subscriptions, products, orders, and company matching, daily at 4 AM.'
|
||||
- cron_expression: '0 4 * * *' (per D-04 — 4:00 AM, grouping with the backend-reconciliation cluster)
|
||||
- sync_type: 'pax8-daily'
|
||||
- is_enabled: false (per D-discretion — every new integration schedule ships disabled; an admin opts in via the schedule editor)
|
||||
|
||||
Use the 089 `WHERE NOT EXISTS` style, NOT 090's `ON CONFLICT (id) DO NOTHING`. All values are static literals — no parameters, no string interpolation of any input (this keeps the seed non-injectable, see threat T-13-02).
|
||||
|
||||
Then apply the migration to the running dev DB: `docker exec -i pulse-postgres psql -U pulse_user -d pulse_autotask < migrations/096_pax8_daily_schedule.sql` (POSTGRES_USER defaults to pulse_user and POSTGRES_DB to pulse_autotask per docker-compose.yml; if either differs in .env, read the real value from .env first). Re-running the file must be a no-op (idempotent) — verify by running it twice.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>docker exec pulse-postgres psql -U pulse_user -d pulse_autotask -tAc "SELECT id, cron_expression, sync_type, is_enabled FROM sync_schedules WHERE sync_type = 'pax8-daily'"</automated>
|
||||
Expected single row: `pax8-daily|0 4 * * *|pax8-daily|f`
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The file `migrations/096_pax8_daily_schedule.sql` exists.
|
||||
- `grep -c "pax8-daily" migrations/096_pax8_daily_schedule.sql` returns >= 2 (id + sync_type).
|
||||
- The migration contains `WHERE NOT EXISTS (SELECT 1 FROM sync_schedules WHERE name = 'PAX8 Daily Sync')` and does NOT contain `ON CONFLICT`.
|
||||
- The migration contains `'0 4 * * *'` and `is_enabled` seeded false (literal `false` in the SELECT projection).
|
||||
- Running the automated query returns exactly one row: sync_type `pax8-daily`, cron `0 4 * * *`, is_enabled `f`.
|
||||
- Applying the file a second time changes no rows (count of pax8-daily rows stays 1).
|
||||
</acceptance_criteria>
|
||||
<done>The pax8-daily schedule row exists in sync_schedules with cron 0 4 * * * and is_enabled=false, seeded idempotently.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add dual-guarded pax8-daily dispatch branch to executeScheduledSync</name>
|
||||
<files>lib/services/sync-scheduler.ts</files>
|
||||
<read_first>
|
||||
- lib/services/sync-scheduler.ts (line 25 sync_type union; lines 413-419 engagement-daily inline-config-guard analog; lines 445-457 appgate dual-branch analog; lines 466-476 success-status update; the defaultSchedules array ~168-310)
|
||||
- lib/services/pax8-factory.ts (isPax8Configured export + exact env var names)
|
||||
- lib/services/pax8-sync-service.ts (getPax8SyncService + fullSync signature, line 74)
|
||||
- lib/services/integration-health.ts (getDbDisabledKeys lines 295-308 — the integration_settings query shape to adapt)
|
||||
- .planning/phases/13-scheduler-admin-toggle/13-PATTERNS.md (sync-scheduler section, exact target branch)
|
||||
</read_first>
|
||||
<action>
|
||||
Two edits, per D-01 and D-03:
|
||||
|
||||
(1) Extend the `sync_type` union at line 25 by adding the literal `'pax8-daily'` (append to the existing union — it currently ends with `... | 'appgate-sessions' | 'appgate-daily' | 'tickets-reconcile'`). This is the only place the type needs extending.
|
||||
|
||||
(2) Add a new `else if (config.sync_type === 'pax8-daily')` branch inside `executeScheduledSync`, placed immediately after the appgate branch (after line 457) to keep integration-toggle blocks together. The branch performs TWO independent guards before running, matching the lazy-dynamic-import style of the appgate branch:
|
||||
- Lazy `await import('@/lib/services/pax8-factory')` for `isPax8Configured`. If NOT configured: `console.log('[SCHEDULER] Skipping pax8-daily — PAX8 not configured')` and do nothing else.
|
||||
- Otherwise query `postgresClient.query<{ disabled: boolean }>("SELECT disabled FROM integration_settings WHERE key = 'pax8'")` (postgresClient is already imported at line 8 — do NOT add an import; use a constant literal SQL string, no interpolation). If `rows[0]?.disabled === true`: `console.log('[SCHEDULER] Skipping pax8-daily — PAX8 disabled via /admin/integrations')` and do nothing else.
|
||||
- Otherwise lazy `await import('@/lib/services/pax8-sync-service')` for `getPax8SyncService`, then `await getPax8SyncService().fullSync('scheduled')`.
|
||||
|
||||
Constraints (do NOT violate):
|
||||
- Do NOT add a pax8-daily entry to the `defaultSchedules` array / createDefaultSchedules() — the seed lives only in migration 096 (Task 1).
|
||||
- Do NOT add mid-flight cancellation to fullSync's per-entity loop (D-03 — a sync already running is allowed to finish; the guard only prevents the NEXT tick from starting).
|
||||
- Do NOT add a failure alert / Teams webhook (D-05 — a failed run just sets sync_schedules.last_status='failed'/last_error via the existing shared success/failure path; leave that path untouched).
|
||||
- Do NOT modify getDbDisabledKeys()/applyDisableOverlay() in integration-health.ts, and do NOT extract a shared helper (D-01 — this is a PAX8-only inline check).
|
||||
- Do NOT touch any other branch in the switch.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npx tsc --noEmit --pretty` passes (proves `'pax8-daily'` is in the union and the branch type-checks).
|
||||
- `grep -c "pax8-daily" lib/services/sync-scheduler.ts` returns >= 2 (union member + branch condition).
|
||||
- The file contains `getPax8SyncService().fullSync('scheduled')`.
|
||||
- The file contains `integration_settings WHERE key = 'pax8'` and both skip log strings: `PAX8 not configured` and `PAX8 disabled via /admin/integrations`.
|
||||
- `grep -n "pax8-daily" lib/services/sync-scheduler.ts` shows NO match inside the defaultSchedules array line range (the seed must not be added in-code).
|
||||
- The pax8-daily branch calls fullSync with the literal argument `'scheduled'` (not `'manual'`).
|
||||
</acceptance_criteria>
|
||||
<done>executeScheduledSync dispatches pax8-daily to fullSync('scheduled') only when PAX8 is both configured and not DB-disabled; tsc passes.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Record the DB-toggle-gates-action precedent in CLAUDE.md</name>
|
||||
<files>CLAUDE.md</files>
|
||||
<read_first>
|
||||
- CLAUDE.md ("Operator config" > "Integration disable" section, and the "Watch out for" section)
|
||||
- .planning/phases/13-scheduler-admin-toggle/13-CONTEXT.md (canonical_refs note flagging this as a behavior precedent)
|
||||
</read_first>
|
||||
<action>
|
||||
In CLAUDE.md's "Operator config" > "Integration disable" section, add a short note that until now the DB toggle (`integration_settings`) only suppressed health-check *display*, and that PAX8 is the first integration where disabling it actually stops an action: the `pax8-daily` scheduler branch skips `fullSync()` and `POST /api/pax8/sync` returns 403 when `key='pax8'` is disabled. Keep it to 2-3 sentences; do not restructure the section or duplicate content from ARCHITECTURE.md. This is documentation only — no behavior change.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -in "pax8" CLAUDE.md</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -in "pax8" CLAUDE.md` returns at least one line inside the Operator config / Integration disable area.
|
||||
- The note mentions both the scheduler skip and the 403 on the manual route.
|
||||
- No other CLAUDE.md section is restructured (only the Operator config note is added).
|
||||
</acceptance_criteria>
|
||||
<done>CLAUDE.md documents PAX8 as the first integration whose DB toggle gates an action, not just display.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| cron scheduler → Pax8SyncService | Scheduled dispatch must respect the operator's disable toggle before triggering a sync |
|
||||
| migration seed → Postgres | Static DDL/DML applied to the sync_schedules table on an existing volume |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-13-01 | Elevation of Privilege | pax8-daily scheduler branch | mitigate | Branch checks `integration_settings.key='pax8'` disabled flag AND `isPax8Configured()` before calling fullSync; a disabled toggle stops the next tick (Task 2) |
|
||||
| T-13-02 | Tampering (SQL injection) | migration 096 seed | mitigate | Seed is static literals only — no parameters, no interpolation of any input; nothing user-controlled reaches the INSERT (Task 1) |
|
||||
| T-13-03 | Denial of Service | repeated scheduled/manual runs | accept | Existing `isSyncInProgress()` 409 guard makes a running sync atomic (D-03); no new surface added this plan |
|
||||
| T-13-SC | Tampering | npm/pip/cargo installs | accept | No package installs in this plan — all four edits use existing dependencies; no Package Legitimacy Gate needed |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npx tsc --noEmit --pretty` passes.
|
||||
- `SELECT * FROM sync_schedules WHERE sync_type='pax8-daily'` returns one is_enabled=false row.
|
||||
- sync-scheduler.ts pax8-daily branch is present with both guards and calls fullSync('scheduled').
|
||||
- Live cron firing + disable-skip behavior is proven in the Wave 2 verification plan (13-03).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Migration 096 seeds the pax8-daily row idempotently (is_enabled false, cron 0 4 * * *).
|
||||
- executeScheduledSync has a dual-guarded pax8-daily branch calling fullSync('scheduled').
|
||||
- Disabling PAX8 in integration_settings makes the branch skip with a distinct log.
|
||||
- No changes to defaultSchedules, no cancellation logic, no failure alerts, no shared helper.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/13-scheduler-admin-toggle/13-01-SUMMARY.md` when done.
|
||||
</output>
|
||||
174
.planning/phases/13-scheduler-admin-toggle/13-02-PLAN.md
Normal file
174
.planning/phases/13-scheduler-admin-toggle/13-02-PLAN.md
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
---
|
||||
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"
|
||||
- "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>
|
||||
115
.planning/phases/13-scheduler-admin-toggle/13-03-PLAN.md
Normal file
115
.planning/phases/13-scheduler-admin-toggle/13-03-PLAN.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
---
|
||||
phase: 13-scheduler-admin-toggle
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [13-01, 13-02]
|
||||
files_modified: []
|
||||
autonomous: false
|
||||
requirements: [PAX8-07, PAX8-09]
|
||||
must_haves:
|
||||
truths:
|
||||
- "The pax8-daily schedule can be enabled from the /admin schedule editor and, once enabled, fires the full PAX8 sync (companies + subscriptions + products + orders + matching) with no code deploy"
|
||||
- "PAX8 appears as a toggleable row on /admin/integrations"
|
||||
- "Disabling PAX8 from /admin/integrations stops the next scheduled pax8-daily run (skip log) AND makes POST /api/pax8/sync return 403"
|
||||
- "Re-enabling PAX8 resumes scheduled sync at the next tick with no container restart"
|
||||
artifacts: []
|
||||
key_links:
|
||||
- from: "/admin/integrations toggle"
|
||||
to: "pax8-daily scheduled run + POST /api/pax8/sync"
|
||||
via: "integration_settings.disabled gate"
|
||||
pattern: "integration_settings"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Prove Phase 13's four success criteria against the running app — the behaviors that
|
||||
cannot be unit-tested (cron firing, admin row rendering, live 403 gating, re-enable
|
||||
resumption). This plan verifies; it does not change code.
|
||||
|
||||
Purpose: Confirm PAX8-07 (daily scheduled sync) and PAX8-09 (toggle on/off without
|
||||
restart, with real enforcement) end-to-end before the phase closes.
|
||||
Output: A human-confirmed pass of ROADMAP Phase 13 SC#1-4.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/13-scheduler-admin-toggle/13-CONTEXT.md
|
||||
@.planning/phases/13-scheduler-admin-toggle/13-01-PLAN.md
|
||||
@.planning/phases/13-scheduler-admin-toggle/13-02-PLAN.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 1: Verify Phase 13 SC#1-4 against the running app</name>
|
||||
<action>
|
||||
Do NOT change code in this plan. Ensure plans 13-01 and 13-02 are merged and the dev
|
||||
app + pulse-postgres are running with PAX8 credentials set, then walk the human through
|
||||
the verification steps below and collect a pass/fail per success criterion.
|
||||
</action>
|
||||
<what-built>
|
||||
- Migration 096 seeded a `pax8-daily` row in sync_schedules (cron 0 4 * * *, is_enabled=false).
|
||||
- `executeScheduledSync` has a dual-guarded pax8-daily branch calling `getPax8SyncService().fullSync('scheduled')` — skips when PAX8 is not configured or DB-disabled.
|
||||
- `checkIntegrationHealth` returns a `pax8` row, so PAX8 renders on /admin/integrations.
|
||||
- `POST /api/pax8/sync` returns 403 when PAX8 is disabled.
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
Preconditions: PAX8_CLIENT_ID and PAX8_CLIENT_SECRET are set in the environment, and the dev app + pulse-postgres are running (docker compose up). Sign in to the app first (the /api/pax8/sync route requires a session — it is NOT in middleware's public list).
|
||||
|
||||
SC#2 — PAX8 is a toggleable row:
|
||||
1. Open `/admin/integrations`. Confirm a "PAX8" row appears (category Finance) with an enable/disable control, alongside the other integrations.
|
||||
|
||||
SC#1 — pax8-daily is scheduled and fires:
|
||||
2. Confirm the schedule row exists:
|
||||
`docker exec pulse-postgres psql -U pulse_user -d pulse_autotask -tAc "SELECT id, cron_expression, sync_type, is_enabled FROM sync_schedules WHERE sync_type='pax8-daily'"`
|
||||
Expect: `pax8-daily|0 4 * * *|pax8-daily|f`.
|
||||
3. Enable the pax8-daily schedule from the /admin schedule editor (or temporarily set its cron to a near-future minute for the test). Confirm a scheduled tick logs `[SCHEDULER]` running pax8-daily and that a PAX8 sync actually runs (watch app logs and/or `SELECT * FROM sync_history WHERE entity_type='pax8' ORDER BY started_at DESC LIMIT 3`). Confirm it ran the full sequence (companies, subscriptions, products, orders, matching) — i.e. `getPax8SyncService().fullSync('scheduled')`, not a partial. Restore the real cron `0 4 * * *` afterward.
|
||||
|
||||
SC#3 — disabling stops runs AND blocks the manual route:
|
||||
4. On `/admin/integrations`, DISABLE PAX8 (optionally with a disabled_reason). Confirm the toggle records disabled_by / disabled_at (visible in the UI or `SELECT key, disabled, disabled_by, disabled_at, disabled_reason FROM integration_settings WHERE key='pax8'`).
|
||||
5. Trigger the manual route while disabled (from an authenticated browser session, e.g. devtools fetch, or curl with your session cookie):
|
||||
`curl -i -X POST http://localhost:3100/api/pax8/sync -b "<your-session-cookie>"`
|
||||
Expect HTTP 403 with body `{"error":"PAX8 is disabled", ...}`. (Without a session you will get redirected/401 — that is auth, not the gate.)
|
||||
6. Wait for (or force) the next scheduled tick while disabled. Confirm the app logs `Skipping pax8-daily — PAX8 disabled via /admin/integrations` and that NO new pax8 sync_history row was created.
|
||||
|
||||
SC#4 — re-enable resumes with no restart:
|
||||
7. RE-ENABLE PAX8 on /admin/integrations (no container restart). Confirm `POST /api/pax8/sync` now returns 200 (`{"ok":true,...}`) and the next scheduled tick runs the sync again.
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" if all four success criteria pass, or describe which step failed (include the observed log line / HTTP status / SQL result).</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| /admin/integrations toggle → sync execution | The disable toggle must govern both the cron dispatch and the manual route — verified live here |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-13-01 | Elevation of Privilege | disable-toggle enforcement | mitigate | Step 5 (manual 403) and Step 6 (cron skip log, no sync_history row) verify the disabled toggle actually stops both paths — the security gate's core requirement |
|
||||
| T-13-SC | Tampering | npm/pip/cargo installs | accept | No package installs — verification only |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Human confirms ROADMAP Phase 13 SC#1 (scheduled + fires full sync), SC#2 (toggleable row),
|
||||
SC#3 (disable stops cron + returns 403), SC#4 (re-enable resumes, no restart).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All four Phase 13 success criteria pass against the running app.
|
||||
- The disable toggle demonstrably stops both the scheduled run and the manual route (no side door).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/13-scheduler-admin-toggle/13-03-SUMMARY.md` when done.
|
||||
</output>
|
||||
Loading…
Add table
Add a link
Reference in a new issue