-
+
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.`)