diff --git a/.github/workflows/wms-connector-boundary-guard.yml b/.github/workflows/wms-connector-boundary-guard.yml new file mode 100644 index 000000000..2b12500c7 --- /dev/null +++ b/.github/workflows/wms-connector-boundary-guard.yml @@ -0,0 +1,33 @@ +name: WMS Connector Boundary Guard + +on: + pull_request: + paths: + - ".github/workflows/wms-connector-boundary-guard.yml" + - "app/**" + - "components/**" + - "lib/**" + - "package-lock.json" + - "package.json" + - "scripts/check-wms-connector-boundary.mjs" + push: + branches: + - development + paths: + - ".github/workflows/wms-connector-boundary-guard.yml" + - "app/**" + - "components/**" + - "lib/**" + - "package-lock.json" + - "package.json" + - "scripts/check-wms-connector-boundary.mjs" + +jobs: + wms-connector-boundary-guard: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm run check:wms-connector-boundary diff --git a/app/(dashboard)/purchase-orders/[id]/po-detail-client.tsx b/app/(dashboard)/purchase-orders/[id]/po-detail-client.tsx index 6b12c1885..2c3521085 100644 --- a/app/(dashboard)/purchase-orders/[id]/po-detail-client.tsx +++ b/app/(dashboard)/purchase-orders/[id]/po-detail-client.tsx @@ -643,9 +643,9 @@ function WmsAsnDialog({
- +
- +
- + setEta(event.target.value)} />
- + setCarrier(event.target.value)} placeholder="e.g. DPD, DHL Freight" />
- + setSupplierReference(event.target.value)} placeholder="Optional supplier or shipment reference" diff --git a/app/(dashboard)/stock-control/transfers/transfer-list.tsx b/app/(dashboard)/stock-control/transfers/transfer-list.tsx index bf8baed3f..b95e25d76 100644 --- a/app/(dashboard)/stock-control/transfers/transfer-list.tsx +++ b/app/(dashboard)/stock-control/transfers/transfer-list.tsx @@ -349,9 +349,9 @@ function WmsTransferAsnDialog({
- +
- +
- + setEta(event.target.value)} />
- + setCarrier(event.target.value)} placeholder="e.g. DPD, DHL Freight" />
- + setSupplierReference(event.target.value)} placeholder="Optional shipment or transfer reference" diff --git a/app/actions/transfers.ts b/app/actions/transfers.ts index 3aeb52b2b..9faa9e40a 100644 --- a/app/actions/transfers.ts +++ b/app/actions/transfers.ts @@ -786,7 +786,7 @@ export async function receiveTransfer(id: string): Promise { } for (const line of transfer.lines) { - // A WMS callback (Mintsoft etc.) may already have booked in part of + // A WMS connector callback may already have booked in part of // this line and stamped qtyReceived + cost layers + a TRANSFER_IN // movement for that portion. Receive only the remaining quantity to // avoid double-counting; skip the line entirely if it is already diff --git a/docs/wms-connector-boundary.md b/docs/wms-connector-boundary.md new file mode 100644 index 000000000..9e58c08b6 --- /dev/null +++ b/docs/wms-connector-boundary.md @@ -0,0 +1,64 @@ +# WMS / 3PL connector boundary (frozen) + +The IMS treats the 3PL/WMS layer as **connector-agnostic**. Core app flows — sales, +purchase orders, transfers, stock, onboarding, settings, the `/sync` wiring, and +external fulfillment — go through a generic boundary and never branch on a +specific connector (`mintsoft`). A second WMS connector (e.g. ShipHero) is added +by **implementing the contract + registering it**, with no edits to those core +flows. + +This mirrors the shopping (`shopping-registry.ts`) and accounting +(`accounting-registry.ts`) boundaries. Epic: `onetwo3d-ims-h02x.10`. + +## The contract (stable, app-facing) + +- **`lib/connectors/wms/types.ts`** — the `WmsConnector` interface plus + `WMS_CONNECTOR_IDS` / `WmsConnectorId` (the single source of truth for connector + ids) and `isWmsConnectorId`. This module is server-free so client components can + import the id guard. +- **`lib/connectors/wms/registry.ts`** — `WMS_CONNECTORS` descriptors + + `getWmsConnector(id)` (resolves the connector implementation). +- **`lib/connectors/wms/active-connector.ts`** — `getActiveWmsConnectorId()` (the + enabled WMS connector, with a single-connector fallback). +- **`lib/connectors/wms/asn-types.ts`** — connector-agnostic ASN view-models. +- **`lib/connectors/wms/order-lookup.ts`** — `resolveWmsOrderLookupConnector`. + +## Dispatch facades (core flows call these, never a connector module) + +- **`app/actions/wms-asn.ts`** — PO/transfer receive-ASN state + create. +- **`app/actions/wms-sync.ts`** — `/sync` dashboard data. +- **`app/actions/wms-onboarding.ts`** — onboarding connection-data. + +Each resolves the active connector and dispatches to its implementation. The +per-connector UI lives in dedicated dispatchers +(`app/(dashboard)/sync/wms-sync-panel.tsx`, +`components/onboarding/wms-onboarding-connection.tsx`) — adding a connector means a +branch there, not in the page/step. + +## Where the `mintsoft` literal is allowed + +The literal is legitimate only in: the Mintsoft connector itself +(`lib/connectors/mintsoft/**`, `app/actions/mintsoft-sync.ts`), its per-connector +ingress (`app/api/cron/mintsoft-*`, `app/api/webhooks/mintsoft/**`, +`app/api/e2e/mintsoft/**`, `app/api/export/mintsoft-sync/**`, `lib/cron-jobs/wms-mintsoft.ts`), +the WMS dispatch facades/registry/panels above, the UI connector registry +(`/sync` `CONNECTORS`, the settings enable toggle), and per-connector ops/security +probes + cosmetic/plugin-registry files. See the allowlist in the guard. + +## Enforcement + +`scripts/check-wms-connector-boundary.mjs` (run by `npm run check:all` and the +**WMS Connector Boundary Guard** CI workflow) fails the build if `mintsoft` appears +in any scanned `app`/`lib`/`components` file outside that allowlist. For a genuinely +connector-specific reference, add the path to the allowlist or add a per-line waiver: + +``` +// wms-connector-boundary-ok: : +``` + +## Known pending generalization + +- `app/actions/products.ts` still triggers Mintsoft product/bundle sync directly on + product mutations (`runMintsoftProductSyncForProduct`). Generalizing this to the + active WMS connector is tracked separately (WMS product-sync dispatch); the file + is allowlisted until then. diff --git a/package.json b/package.json index a8ff08541..67905370d 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,9 @@ "test:unit": "NODE_OPTIONS='--import tsx' node --test \"tests/**/*.test.ts\"", "check:decimal-boundaries": "node scripts/check-domain-decimal-boundaries.mjs", "check:connector-fetch-boundaries": "node scripts/check-connector-fetch-boundaries.mjs", + "check:wms-connector-boundary": "node scripts/check-wms-connector-boundary.mjs", "check:migration-conventions": "node scripts/check-migration-conventions.mjs", - "check:all": "npm run check:decimal-boundaries && npm run check:connector-fetch-boundaries && npm run check:migration-conventions", + "check:all": "npm run check:decimal-boundaries && npm run check:connector-fetch-boundaries && npm run check:wms-connector-boundary && npm run check:migration-conventions", "test:concurrency": "RUN_DB_CONCURRENCY_TESTS=1 NODE_OPTIONS='--import tsx' node --test \"tests/concurrency/**/*.test.ts\"", "docs:workflows": "tsx scripts/generate-workflow-docs.ts", "docs:workflows:check": "tsx scripts/generate-workflow-docs.ts --check", diff --git a/scripts/check-wms-connector-boundary.mjs b/scripts/check-wms-connector-boundary.mjs new file mode 100644 index 000000000..a0920dd79 --- /dev/null +++ b/scripts/check-wms-connector-boundary.mjs @@ -0,0 +1,161 @@ +#!/usr/bin/env node + +/** + * Static guard: keeps the 3PL/WMS layer connector-agnostic by blocking the + * `mintsoft` literal from leaking into core app flows. Core flows (sales / PO / + * transfer / stock / onboarding / settings / sync wiring / fulfillment) must go + * through the generic WMS boundary — the WmsConnector contract + * (lib/connectors/wms/types.ts), the WMS registry, and the dispatch facades + * (app/actions/wms-asn.ts, wms-sync.ts, wms-onboarding.ts) — never a + * connector-specific branch. See docs/wms-connector-boundary.md. + * + * It scans app/**, lib/**, components/** for `mintsoft`/`Mintsoft` and reports + * any reference that is NOT in an allowlisted path. Allowlisted paths are the + * legitimate homes for the literal: the Mintsoft connector itself, its + * per-connector ingress endpoints, the WMS dispatch facades / registry / panels + * that resolve the active connector, the UI connector registry, and a few + * cosmetic/plugin-registry files. + * + * Per-line waiver: add `// wms-connector-boundary-ok: : ` + * on the same line as the reference or the line immediately above it. + * + * Run via `npm run check:wms-connector-boundary`; invoked by `npm run check:all`. + */ + +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { extname, join, sep } from 'node:path' + +const ROOT = process.cwd() +const SCAN_ROOTS = ['app', 'lib', 'components'] +const SCANNED_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']) +const SKIPPED_DIRECTORIES = new Set(['.git', '.next', 'node_modules', 'build', 'dist', 'out', 'coverage']) +const MINTSOFT_RE = /mintsoft/i +const WAIVER_RE = /wms-connector-boundary-ok:\s*[^:\s]+:\s*\S+/i + +/** + * Paths where the `mintsoft` literal is legitimately allowed. A scanned file is + * exempt when its repo-relative path starts with any of these prefixes. + * + * Connector implementation + per-connector ingress (the literal IS the point): + * lib/connectors/mintsoft/, app/actions/mintsoft-sync.ts, app/api/cron/mintsoft-*, + * app/api/webhooks/mintsoft/, app/api/e2e/mintsoft, app/api/export/mintsoft-sync/, + * app/api/admin/wms/, lib/cron-jobs/wms-mintsoft.ts, lib/connectors/mintsoft webhook/jobs. + * WMS boundary that resolves/dispatches to the active connector: + * lib/connectors/wms/, lib/cron-jobs/wms.ts, app/actions/wms-asn.ts|wms-sync.ts|wms-onboarding.ts, + * app/(dashboard)/sync/wms-sync-panel.tsx|mintsoft-client.tsx, + * components/onboarding/wms-onboarding-connection.tsx, + * lib/domain/wms/, lib/jobs/wms/, lib/domain/integrations/outbox-registry.ts. + * UI connector registry / per-connector enable toggle (parallel to woo/shopify/xero): + * app/(dashboard)/sync/sync-dashboard.tsx, app/(dashboard)/settings/system/page.tsx, + * components/settings/integration-plugins-settings.tsx. + * Per-connector ops/security probes + cosmetic/plugin registry: + * lib/ops/health.ts, lib/ops/rollout-readiness.ts, lib/security/route-auth-policy.ts, + * lib/security/public-route-security-policy.ts, lib/integration-plugins.ts, + * lib/integration-connection-test-gate.ts, lib/settings-store.ts, lib/releases.ts. + * Plugin-enable persistence that enumerates every connector (woo/shopify/xero/qb/wms): + * app/actions/onboarding.ts (saveOnboardingPluginState). + * Known pending generalization (tracked by onetwo3d-ims-h02x.10.7 — WMS product-sync dispatch): + * app/actions/products.ts. + */ +const ALLOWLIST = [ + 'lib/connectors/mintsoft/', + 'lib/connectors/wms/', + 'app/actions/mintsoft-sync.ts', + 'app/actions/wms-asn.ts', + 'app/actions/wms-sync.ts', + 'app/actions/wms-onboarding.ts', + 'app/api/cron/mintsoft-', + 'app/api/webhooks/mintsoft/', + 'app/api/e2e/mintsoft', + 'app/api/export/mintsoft-sync/', + 'app/api/admin/wms/', + 'lib/cron-jobs/wms-mintsoft.ts', + 'lib/cron-jobs/wms.ts', + 'app/(dashboard)/sync/mintsoft-client.tsx', + 'app/(dashboard)/sync/wms-sync-panel.tsx', + 'app/(dashboard)/sync/sync-dashboard.tsx', + 'components/onboarding/wms-onboarding-connection.tsx', + 'components/settings/integration-plugins-settings.tsx', + 'app/(dashboard)/settings/system/page.tsx', + 'lib/domain/wms/', + 'lib/jobs/wms/', + 'lib/domain/integrations/outbox-registry.ts', + 'lib/ops/health.ts', + 'lib/ops/rollout-readiness.ts', + 'lib/security/route-auth-policy.ts', + 'lib/security/public-route-security-policy.ts', + 'lib/integration-plugins.ts', + 'app/actions/onboarding.ts', + 'lib/integration-connection-test-gate.ts', + 'lib/settings-store.ts', + 'lib/releases.ts', + 'app/actions/products.ts', +] + +function isScannedFile(file) { + if (!SCANNED_EXTENSIONS.has(extname(file))) return false + return !/\.(test|spec)\.[cm]?[jt]sx?$/.test(file) +} + +function listFiles(path) { + const fullPath = join(ROOT, path) + let stats + try { + stats = statSync(fullPath) + } catch { + return [] + } + if (stats.isFile()) return isScannedFile(fullPath) ? [path] : [] + if (!stats.isDirectory()) return [] + + const files = [] + for (const entry of readdirSync(fullPath)) { + if (SKIPPED_DIRECTORIES.has(entry)) continue + files.push(...listFiles(join(path, entry))) + } + return files +} + +function isAllowlisted(relPath) { + return ALLOWLIST.some((entry) => { + // Directory prefix ('…/') or an explicit filename prefix ('…mintsoft-') + // match by startsWith; everything else is an exact file (or a directory + // given without a trailing slash) and must match the path or a child of it, + // so a sibling like `foo.tsx`/`foo-extra/` is NOT exempted by `foo.ts`/`foo`. + if (entry.endsWith('/') || entry.endsWith('-')) return relPath.startsWith(entry) + return relPath === entry || relPath.startsWith(`${entry}/`) + }) +} + +function findLeaks(relPath) { + const lines = readFileSync(join(ROOT, relPath), 'utf8').split(/\r?\n/) + const findings = [] + for (let i = 0; i < lines.length; i += 1) { + if (!MINTSOFT_RE.test(lines[i])) continue + const onLine = WAIVER_RE.test(lines[i]) + const onPrev = i > 0 && WAIVER_RE.test(lines[i - 1]) + if (onLine || onPrev) continue + findings.push({ path: relPath, line: i + 1, text: lines[i].trim() }) + } + return findings +} + +const files = SCAN_ROOTS.flatMap((root) => listFiles(root)) + .map((p) => p.split(sep).join('/')) +const findings = files + .filter((relPath) => !isAllowlisted(relPath)) + .flatMap(findLeaks) + +if (findings.length > 0) { + console.error('WMS connector boundary violation: the `mintsoft` literal is not allowed in core app flows.') + console.error('Route through the generic WMS boundary (WmsConnector contract + wms-* facades). See docs/wms-connector-boundary.md.') + console.error('If the reference is genuinely connector-specific, add it to the allowlist in this script or add a waiver:') + console.error('// wms-connector-boundary-ok: : ') + console.error('') + for (const finding of findings) { + console.error(`${finding.path}:${finding.line}: ${finding.text}`) + } + process.exit(1) +} + +console.log(`WMS connector boundary clean — scanned ${files.length} files, no mintsoft leaks outside the allowlist.`)