Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/wms-connector-boundary-guard.yml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 10 additions & 10 deletions app/(dashboard)/purchase-orders/[id]/po-detail-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -643,9 +643,9 @@ function WmsAsnDialog({

<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="mintsoftPackagingType">Packaging Type</Label>
<Label htmlFor="wmsPackagingType">Packaging Type</Label>
<Select
id="mintsoftPackagingType"
id="wmsPackagingType"
value={packagingType}
onChange={(event) => setPackagingType(event.target.value as typeof packagingType)}
className="h-9 rounded-md px-3"
Expand All @@ -656,9 +656,9 @@ function WmsAsnDialog({
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="mintsoftPackageCount">Package Count</Label>
<Label htmlFor="wmsPackageCount">Package Count</Label>
<Input
id="mintsoftPackageCount"
id="wmsPackageCount"
type="number"
min={1}
step={1}
Expand All @@ -667,27 +667,27 @@ function WmsAsnDialog({
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="mintsoftEta">ETA</Label>
<Label htmlFor="wmsEta">ETA</Label>
<Input
id="mintsoftEta"
id="wmsEta"
type="date"
value={eta}
onChange={(event) => setEta(event.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="mintsoftCarrier">Carrier</Label>
<Label htmlFor="wmsCarrier">Carrier</Label>
<Input
id="mintsoftCarrier"
id="wmsCarrier"
value={carrier}
onChange={(event) => setCarrier(event.target.value)}
placeholder="e.g. DPD, DHL Freight"
/>
</div>
<div className="space-y-1.5 md:col-span-2">
<Label htmlFor="mintsoftSupplierReference">Supplier Reference</Label>
<Label htmlFor="wmsSupplierReference">Supplier Reference</Label>
<Input
id="mintsoftSupplierReference"
id="wmsSupplierReference"
value={supplierReference}
onChange={(event) => setSupplierReference(event.target.value)}
placeholder="Optional supplier or shipment reference"
Expand Down
20 changes: 10 additions & 10 deletions app/(dashboard)/stock-control/transfers/transfer-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -349,9 +349,9 @@ function WmsTransferAsnDialog({

<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="mintsoftTransferPackagingType">Packaging Type</Label>
<Label htmlFor="wmsTransferPackagingType">Packaging Type</Label>
<select
id="mintsoftTransferPackagingType"
id="wmsTransferPackagingType"
value={packagingType}
onChange={(event) => setPackagingType(event.target.value as typeof packagingType)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
Expand All @@ -362,9 +362,9 @@ function WmsTransferAsnDialog({
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="mintsoftTransferPackageCount">Package Count</Label>
<Label htmlFor="wmsTransferPackageCount">Package Count</Label>
<Input
id="mintsoftTransferPackageCount"
id="wmsTransferPackageCount"
type="number"
min={1}
step={1}
Expand All @@ -373,27 +373,27 @@ function WmsTransferAsnDialog({
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="mintsoftTransferEta">ETA</Label>
<Label htmlFor="wmsTransferEta">ETA</Label>
<Input
id="mintsoftTransferEta"
id="wmsTransferEta"
type="date"
value={eta}
onChange={(event) => setEta(event.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="mintsoftTransferCarrier">Carrier</Label>
<Label htmlFor="wmsTransferCarrier">Carrier</Label>
<Input
id="mintsoftTransferCarrier"
id="wmsTransferCarrier"
value={carrier}
onChange={(event) => setCarrier(event.target.value)}
placeholder="e.g. DPD, DHL Freight"
/>
</div>
<div className="space-y-1.5 md:col-span-2">
<Label htmlFor="mintsoftTransferReference">Shipment Reference</Label>
<Label htmlFor="wmsTransferReference">Shipment Reference</Label>
<Input
id="mintsoftTransferReference"
id="wmsTransferReference"
value={supplierReference}
onChange={(event) => setSupplierReference(event.target.value)}
placeholder="Optional shipment or transfer reference"
Expand Down
2 changes: 1 addition & 1 deletion app/actions/transfers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -786,7 +786,7 @@ export async function receiveTransfer(id: string): Promise<TransferResult> {
}

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
Expand Down
64 changes: 64 additions & 0 deletions docs/wms-connector-boundary.md
Original file line number Diff line number Diff line change
@@ -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: <ticket-or-date>: <reason>
```

## 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.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
161 changes: 161 additions & 0 deletions scripts/check-wms-connector-boundary.mjs
Original file line number Diff line number Diff line change
@@ -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: <ticket-or-date>: <reason>`
* 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: <ticket-or-date>: <reason>')
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.`)
Loading