medusa-plugin-ordinant
v0.1.5
Published
Ordinant compliance for Medusa v2: catalog classification with merchant attestation and a deterministic, citation-bearing checkout gate. Compliance information and automation - not legal advice.
Downloads
49
Maintainers
Readme
medusa-plugin-ordinant
Ordinant compliance for Medusa v2: AI-proposed product classification with merchant attestation, and a deterministic, citation-bearing checkout gate.
Published on npm — install with npm install medusa-plugin-ordinant
(npmjs.com/package/medusa-plugin-ordinant).
Ordinant is a compliance information and automation service — not legal advice. No attorney–client relationship is created by its use. Merchants attest to the final classification of their products.
Contents
- What it does
- Requirements
- Installation
- Configuration
- How it works
- The admin dashboard
- The per-product widget
- The checkout gate
- Buyer verification assertions
- Recommended: early compliance preview
- Onboarding an existing catalog
- Admin API reference
- Store API reference
- Troubleshooting
- Local development
What it does
| Moment | What happens | |---|---| | Product created or updated | Variants are sent to Ordinant; an LLM proposes dictionary attributes with a confidence score | | Merchant opens the product page | The Ordinant compliance widget shows each SKU's status; the merchant confirms or edits the proposal — recording their attestation | | Merchant opens the Ordinant dashboard | A catalog-wide triage queue: an "illegal orders stopped" impact metric, and bulk SKU-generation + classification + confirmation | | Buyer completes checkout | Every line item is decided against the destination's rules; blocks and unmet requirements stop the order with statute citations in the error message |
Three properties worth knowing up front:
- No storefront changes required for the gate. It runs inside the backend's cart completion workflow; failures surface through the standard checkout error path every storefront already renders. (An optional early-preview UX can be wired in, and recommended — see Recommended: early compliance preview.)
- Fail closed, always. Unclassified SKUs cannot sell. An unreachable
decision engine blocks checkout (configurable, see
failMode). A wrong "allow" never happens silently. - The plugin holds no compliance logic. Decisions are made, logged, and replayable on the Ordinant server; the plugin carries data and citations.
Requirements
- Medusa v2.4+ (built and tested against 2.17)
- Node.js 20+
- A running Ordinant deployment and a merchant API key
- Ordinant's catalog-classification module enabled server-side
(
ORDINANT_CLASSIFY_ENABLED=trueon the Ordinant deployment) if you want automatic proposals — the widget and gate work without it, but proposals must then be created via Ordinant's API directly
Installation
1. Install the package
npm install medusa-plugin-ordinant
# or: yarn add medusa-plugin-ordinant / pnpm add medusa-plugin-ordinantpnpm users: ensure
@tanstack/react-querymatches the version used by@medusajs/dashboard(see Medusa's admin-customization docs) or the admin widget will fail to resolve its peer dependency.
2. Register the plugin
In medusa-config.ts:
module.exports = defineConfig({
projectConfig: {
// ... your existing config
},
plugins: [
{
resolve: "medusa-plugin-ordinant",
options: {
baseUrl: process.env.ORDINANT_BASE_URL,
apiKey: process.env.ORDINANT_API_KEY,
},
},
],
})3. Set environment variables
In the backend's .env:
ORDINANT_BASE_URL=https://your-ordinant-deployment.example
ORDINANT_API_KEY=ord_live_...The API key is a merchant key issued by your Ordinant deployment
(ORDINANT_API_KEYS on the server). It never reaches the browser: the admin
widget talks to plugin-provided backend routes, which call Ordinant
server-side.
4. Restart and verify
npm run devIf baseUrl or apiKey is missing the plugin does not crash — it boots
into a degraded, un-activated state: the Ordinant dashboard shows an
activation gate instead of data, and the checkout gate
treats the engine as unreachable (honoring failMode). A half-configured
install never takes down the backend.
To verify a good connection, open the Ordinant page in the admin sidebar:
the header's engine-health pill should read "Engine connected · <ruleset
version>" and your catalog should load.
Configuration
All plugin options:
| Option | Required | Default | Purpose |
|---|---|---|---|
| baseUrl | ✅ | — | Ordinant API base URL |
| apiKey | ✅ | — | Merchant bearer key |
| timeoutMs | — | 5000 | Per-request timeout; checkout must not hang |
| failMode | — | "closed" | Gate behavior when Ordinant is unreachable: "closed" blocks completion; "open" completes the order and logs an error. Choose "open" only as a deliberate, documented risk decision — it means orders ship unchecked during an outage. |
| skuMode | — | "block" | What the gate does with a cart line that has no SKU. Ordinant identifies (and gates) products by SKU, so a SKU-less item can't be checked. "block" fails closed — checkout stops with a message to assign a SKU. "allow" skips SKU-less items; choose it only if you knowingly sell unregulated products without SKUs (gift cards, services) and accept they are never gated. |
How it works
product.created / product.updated
│ (subscriber)
▼
classifyProductWorkflow ──► POST {ordinant}/v1/catalog/classify
│ LLM proposes attributes + confidence
▼
proposal stored on Ordinant (status: proposed — cannot sell yet)
│
▼ merchant reviews in the product-page widget
POST /admin/ordinant/classifications/{sku}/confirm
│ attestation recorded (merchant is the classifier of record)
▼
SKU is confirmed — checkout decisions now resolve its attributes
│
▼ buyer completes checkout
completeCartWorkflow.hooks.validate
└─► POST {ordinant}/v1/decisions (bare SKUs + destination + buyer)
ALLOW → order places
CONDITIONAL/BLOCK → completion stops, citations in the errorClassification failures (Ordinant down, module disabled, odd product data) are logged and swallowed — catalog work is never disrupted. The SKU simply stays unclassified, which the gate fails closed on.
The admin dashboard
A dedicated Ordinant page in the admin sidebar (its icon swaps to your logo — see Branding) is the catalog-wide cockpit the per-product widget can't give you. It's built as a triage queue: it surfaces what needs action first, not a wall of everything.
- Activation gate. Until a valid API key is configured, the whole page is blurred behind a lock card linking to your Ordinant sign-up — an un-activated install looks intentional, not broken.
- Welcome header + engine-health pill. A greeting (by the signed-in admin's
first name) and a live status pill — Engine connected ·
<ruleset version>, Engine unreachable, or Not activated — fromGET /admin/ordinant/status. - Impact hero — "Illegal orders stopped." The headline metric, from
GET /admin/ordinant/stats: an animated count of blocked orders with a Day / Month / Year / All toggle, a trend chart, and KPI cards (all-time stopped, top blocked destination, stopped this year). Before the first block it reads "You're protected — no illegal orders yet" rather than a bare zero. - Filter tiles. Click to filter: Needs attention (the default — everything actionable: needs-SKU + unclassified + to-review), Needs SKU, Unclassified, To review, Confirmed, All. A caught-up queue shows a calm "You're all caught up — N SKUs protected" state.
- Search + pagination. Filter by product title or SKU. The queue views paginate product cards; All / Confirmed switch to a dense, paginated table (editing a row there opens a side drawer) so hundreds of SKUs stay scannable. Filter, search, and page are kept in the URL — shareable and back-button friendly.
- Bulk actions (top-right, shown only when relevant):
- Generate N SKUs — assign readable, collision-free SKUs to every SKU-less variant, then auto-classify them: one hands-off action with a live progress bar.
- Classify N — classify the unclassified backlog in chunks.
- Confirm N ready — attest high-confidence proposals (≥ 85%) in one go. Because every confirmation is a legal attestation, the dialog shows a category-grouped breakdown of exactly what you're attesting ("212 accessories, 40 magazines, 12 handguns"), not a bare count.
Attributes are shown as plain-language chips (Magazine · 30 rounds · non-NFA),
never raw JSON. Typical onboarding: open the page → Generate N SKUs →
Classify N → Confirm N ready for the confident batch → review the handful
that need judgment with the dropdown editor.
Branding
Drop your company media in one place — src/admin/components/brand-assets.tsx:
export const BRAND = {
name: "Ordinant",
logo: "", // square logo (data: URI or imported asset) → sidebar icon, gate, empty-state marks
wordmark: "", // optional horizontal wordmark → welcome header
website: "https://ordinant.com", // gate CTA target
}Set logo and the sidebar icon, activation gate, and empty-state marks all pick
it up; leave it empty to use the built-in shield. These are build-time constants,
so set them before building the plugin.
The per-product widget
On every product page (product.details.after zone):
- Per-SKU status:
unclassified,proposed · NN%(hover for the model's notes), orconfirmed(hover for the attestation timestamp). - Generate SKUs for SKU-less variants — the same helper as the dashboard.
- Confirm accepts the proposal as-is. Edit opens a dropdown/checkbox attribute editor generated from the compliance dictionary — never raw JSON — so the confirmed set is your assertion, corrected in a guided form.
- Classify (re-)submits the product's variants — use it for products created before the plugin was installed, or after editing product data.
- Invalid attribute edits are rejected by Ordinant's dictionary validation and shown inline — garbage can't be attested.
Category-adaptive fields. The editor shows only the attributes relevant to
the chosen category — pick optic and you won't see magazine capacity, action
type, or NFA class; pick knife and you get blade type and length. This is
built into the plugin (a category → fields map) and also honors the engine
dictionary's applies_to when present, so it's correct without waiting on a
dictionary change.
Re-classifying a SKU (e.g. after a product edit) resets it to proposed: a
stale attestation never survives changed product data.
Live shipping-impact preview. While editing a classification, the widget
shows "With this configuration, shipping/checkout will be denied to: CA, NY, …"
(and a separate "requires FFL/permit/age in …" list), swept across every modeled
jurisdiction — so you see exactly where a classification will and won't sell
before you attest to it. Fields with subtle legal meaning carry inline
guidance (e.g. for a suppressor part, nfa_class = Suppressor for the sound-
reducing core, None for an external accessory like an end cap).
The checkout gate
Consumes completeCartWorkflow.hooks.validate, which runs before any order
is created. Semantics:
- Items: every cart line is checked by its
variant_sku. A line with no SKU cannot be identified or gated, so it fails closed (blocks checkout) by default — silently skipping it would be a fail-open hole on a compliance gate. Give every sellable variant a SKU; or setskuMode: "allow"if you knowingly sell unregulated SKU-less items and accept they go ungated. (In Medusa every product has at least one variant, but a variant's SKU is optional — this is about SKU presence, not variant count.) - Bare-SKU decisions: the gate sends empty attributes; Ordinant resolves the merchant's confirmed classifications server-side. Proposed-but- unconfirmed classifications are never used.
- Destination: from the cart's shipping address (
province= US state). A missing state blocks completion with a clear message. Non-US destinations skip the gate entirely (Ordinant is US-only in v1) with a warning log. - Outcomes:
ALLOW→ order proceeds (the decision ID and ruleset version are logged for audit).BLOCK/ unmetCONDITIONAL→ aMedusaErrorstops completion; the message lists each problem SKU with its requirement or prohibition and the statute citations. Storefronts render this through their normal checkout error handling — the Next.js starter shows it on the payment step with no modification. - Idempotency: decisions are keyed
medusa-cart:{cart_id}, so checkout retries never double-log on the Ordinant side. - Unclassified SKUs produce a distinct message ("product is not yet classified for compliance") so merchants immediately know it's an onboarding gap, not a legal block.
Buyer verification assertions
Conditional effects (age gates, FFL routing, permits) are satisfied by
verification assertions — Ordinant never receives identity documents.
Upstream integrations (BlueCheck/AgeChecker-style age verification, FFL
selection UIs) should write their results to cart.metadata.ordinant_buyer
before completion:
{
"age_verified": true,
"verified_age": 27,
"ffl_verified": true,
"sot_verified": false,
"permits": ["il_foid"],
"fulfillment_method": "ship"
}Until such an integration exists, conditional requirements simply block completion with a message stating exactly what's missing (e.g. "route shipment to a verified FFL [18 U.S.C. § 922(b)(3)]") — which is the correct fail-safe default.
Recommended: early compliance preview (progressive checkout UX)
Strongly recommended for every storefront. It's a few lines to wire, it can't break checkout (fail-open, backstopped by the mandatory gate), and it transforms the experience: the buyer learns their order can't ship to their state — with the statute citation — the instant they enter the address, right where they can fix it, instead of after they've entered a card at "Place Order". This is the difference between compliance that feels like a dead end and compliance that feels helpful.
The checkout gate above is the authoritative, non-bypassable enforcement point: it runs server-side at cart completion and stops non-compliant orders no matter what the storefront does. That is mandatory and needs zero storefront work.
Surfacing a problem earlier — the moment a buyer picks a shipping state instead of at "Place Order" — is the recommended UX layer on top. The plugin exposes an advisory, non-logging preview for exactly this:
POST /store/ordinant/preview(storefront, publishable key) — body{ "cart_id": "...", "destination"?: { "state", ... }, "buyer"?: { ... } }. It resolves the cart's items server-side; the destination comes from the optionaldestination(a proposed, not-yet-saved address) or the cart's saved shipping address. Returns what the gate would decide. It never blocks and fails open — it is purely advisory.- It proxies to Ordinant's
POST /v1/decisions/preview, which is non-logging by default so re-previews don't inflate anything — with one deliberate exception: a BLOCK is recorded once per cart (deduped by an idempotency key the store route sends). A buyer turned away at the address step is a real "stopped illegal order," so it counts toward the merchant's blocked-order metric — but repeat previews of the same cart never double-count, and ALLOW/CONDITIONAL previews are never logged.
Two rules keep this safe:
- The preview is advisory; the completeCart gate is the guarantee. Client checks are bypassable, so they can never be the enforcement point. Always keep the gate — the preview only complements it.
- Surface each requirement where its input appears. The destination verdict (PII-free) resolves as soon as the shipping state is known — the highest-value early signal, and it fires before payment authorization, so you never authorize a card you're about to block. Age/FFL requirements surface at their own steps once those assertions exist.
Wiring it into your checkout
Two small steps — the same everywhere; only where you hook in differs.
Step 1 — find where your checkout saves the shipping address. That's the action behind your "Continue to delivery" (or equivalent) button.
| Storefront | Hook point |
|---|---|
| Medusa Next.js starter | the setAddresses server action in src/lib/data/cart.ts |
| Any custom storefront | wherever you call sdk.store.cart.update({ shipping_address }) (i.e. POST /store/carts/{id}) to save the address |
Step 2 — check the proposed address before you save it, and stop if it's blocked. Import the helper the plugin ships:
import { checkAddressCompliance } from "medusa-plugin-ordinant/storefront"
// in your address-submit handler, BEFORE you persist the address:
const block = await checkAddressCompliance(sdk, {
cartId,
destination: { state, country_code }, // the values the buyer just entered
})
if (block) {
return block // show `block`; do NOT save the address
}
await saveTheAddress() // your existing cart updatesdkis your Medusa JS SDK (anything with aclient.fetch).blockis a ready-to-display message with statute citations, ornull.- Fail-open: any error, or an unconfigured plugin, returns
null— it can never break checkout. Only a hard BLOCK returns a message; aCONDITIONAL(age/FFL not gathered yet) returnsnulland proceeds to the next step. - Checking the proposed
destinationbefore saving means a blocked address is never written to the cart — a buyer who backs out and re-enters checkout won't find a restricted address waiting.
Display: render the returned string wherever your form shows errors. In the
Next.js starter, setAddresses already returns its errors to an
<ErrorMessage>, so you just return block — no component change:
// src/lib/data/cart.ts
import { checkAddressCompliance } from "medusa-plugin-ordinant/storefront"
export async function setAddresses(currentState: unknown, formData: FormData) {
try {
// ...parse the form into `data`...
const block = await checkAddressCompliance(sdk, {
cartId,
destination: {
state: String(formData.get("shipping_address.province") ?? ""),
country_code: String(formData.get("shipping_address.country_code") ?? ""),
},
})
if (block) return block // stops the redirect; shown by <ErrorMessage>
await updateCart(data) // save only when clear
} catch (e: any) {
return e.message
}
redirect(`/${countryCode}/checkout?step=delivery`)
}Prefer not to add the dependency? checkAddressCompliance is a single,
self-contained function — copy it from src/storefront.ts into your codebase
instead. Either way, anything you don't wire is still caught by the completeCart
gate; the preview is pure upside.
Also re-check when the shopper enters checkout (defense in depth)
The address-step check catches a bad address as it's entered. But a shopper can set a compliant address, then add an item that's illegal to their state, and walk up to the review step — where the block would otherwise only appear at "Place Order". Close that gap by re-checking the whole cart when they land on (or return to) the checkout page, and bouncing them back to the shipping step if it's blocked.
The same preview does it — pass the cartId with no destination and it
evaluates the cart's saved address against its current items server-side:
import { checkAddressCompliance } from "medusa-plugin-ordinant/storefront"
// on entering checkout (any storefront):
const block = await checkAddressCompliance(sdk, { cartId }) // no destination = full current cart
if (block) {
// send them back to the shipping step and show `block`
}In the Medusa Next.js starter, do it in the checkout page (a server component) so the redirect happens before payment/review render:
// src/app/[countryCode]/(checkout)/checkout/page.tsx
import { getCartComplianceBlock } from "@lib/data/cart" // thin wrapper over the preview
import { redirect } from "next/navigation"
const complianceBlock = await getCartComplianceBlock(cart.id)
if (complianceBlock && step !== "address" && step !== "delivery") {
redirect(`/${countryCode}/checkout?step=delivery`) // bounce back to shipping
}
// ...and render `complianceBlock` as a banner above <CheckoutForm />.It only redirects when the shopper is past the shipping step, and it targets the shipping step — so there's no redirect loop, and they can fix the address or remove the item. Fail-open, same as the address check.
The three layers together: (1) the address step vets the address as it's entered, (2) checkout entry re-checks the whole cart, (3) the completeCart gate is the non-bypassable backstop at "Place Order". Wire 1 and 2, or neither — 3 always holds.
Onboarding an existing catalog
Products created after installation classify automatically. For the existing catalog:
- Small catalogs: open each product and click Classify.
- Bulk: call Ordinant's
POST /v1/catalog/classifydirectly with your merchant key, in chunks of ~100 SKUs per request (see the Ordinant API reference for the payload). Then confirm in the widget, or viaPOST /v1/catalog/classifications/{sku}/confirmfor programmatic confirmation of high-confidence proposals — remembering that confirmation is your attestation.
Classify everything, including non-regulated products (t-shirts and
cleaning kits classify as accessory): the gate fails closed on
unclassified SKUs by design. An unclassified hoodie blocking checkout is an
onboarding gap; a misclassified suppressor shipping is a felony. The design
prefers the first failure.
Admin API reference
Routes the plugin adds to your Medusa backend (admin-authenticated; they proxy to Ordinant server-side):
| Route | Purpose |
|---|---|
| GET /admin/ordinant/overview | Every store variant × classification state (incl. needs_sku) + summary counts |
| GET /admin/ordinant/products/{id}/overview | Per-product version of the above (the widget's display query) |
| GET /admin/ordinant/dictionary | The attribute dictionary the editor renders its dropdowns from |
| POST /admin/ordinant/products/{id}/generate-skus | Assign readable, collision-free SKUs to a product's SKU-less variants |
| POST /admin/ordinant/classify-all | Classify the next chunk of unclassified SKUs; returns remaining so the caller loops |
| POST /admin/ordinant/classifications/bulk-confirm | Attest a batch of proposals as-is; body {"skus": [...]} |
| GET /admin/ordinant/classifications?skus=a,b,c | Classification records, optionally filtered to specific SKUs (the widget's display query) |
| POST /admin/ordinant/classifications/{sku}/confirm | Record the merchant's attestation; body {"attributes": {...}} |
| POST /admin/ordinant/products/{id}/classify | (Re-)classify a product's variants on demand |
| GET /admin/ordinant/status | Readiness for the dashboard: configured, engine_reachable, ruleset_version |
| GET /admin/ordinant/stats | Blocked-order metrics ("stopped illegal orders") for the impact hero |
Store API reference
Routes the plugin adds for the storefront (publishable-key auth, guest-safe):
| Route | Purpose |
|---|---|
| POST /store/ordinant/preview | Advisory, non-logging compliance preview for a cart; body {"cart_id": "...", "destination"?: {...}, "buyer"?: {...}} — optional destination vets a proposed address before it's saved. Never blocks, fails open. See Recommended: early compliance preview. |
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| Dashboard shows the activation gate / health pill reads "Not activated" | ORDINANT_BASE_URL / ORDINANT_API_KEY are unset or empty — set them and pass them in the plugin options, then restart. The plugin degrades gracefully rather than crashing when unconfigured |
| Health pill reads "Engine unreachable" or widget shows "Could not reach Ordinant" | Ordinant deployment down or wrong baseUrl; check backend logs for the underlying error |
| Widget loads but classify returns an error | Ordinant's classification module is disabled server-side — set ORDINANT_CLASSIFY_ENABLED=true + ANTHROPIC_API_KEY on the Ordinant deployment (not in Medusa) |
| Every checkout is blocked with "not yet classified" | Working as designed — confirm classifications for every sellable SKU, including non-regulated ones |
| Checkout blocked with "Compliance check is temporarily unavailable" | Ordinant unreachable and failMode is closed (default). Restore connectivity; only set failMode: "open" if you accept unchecked orders during outages |
| Checkout blocked with a permit/FFL/age requirement | Correct legal outcome for that destination — the citation is in the message. Recovering these sales requires the corresponding verification integration writing cart.metadata.ordinant_buyer |
| 401s from Ordinant in logs | Wrong or rotated merchant key |
| Gate never fires | Cart items have no variant_sku, or the destination isn't US |
Local development
This plugin lives in a workspace inside the ordinant-test monorepo:
# build the plugin (server + admin extensions)
cd plugins/medusa-plugin-ordinant && npm run build
# run the backend against it (workspace-linked automatically)
cd apps/backend && npm run dev
# live-reload plugin development (publishes to the local yalc registry)
cd plugins/medusa-plugin-ordinant && npm run devPoint ORDINANT_BASE_URL at a local engine (go run ./cmd/ordinantd in the
Ordinant repo, default http://localhost:8080) for a fully local loop.
To publish: npm publish (the prepublishOnly hook builds .medusa/server).
