medusa-plugin-ordinant 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/.medusa/server/src/admin/index.js +1636 -0
  2. package/.medusa/server/src/admin/index.mjs +1635 -0
  3. package/.medusa/server/src/api/admin/ordinant/classifications/[sku]/confirm/route.js +25 -0
  4. package/.medusa/server/src/api/admin/ordinant/classifications/bulk-confirm/route.js +29 -0
  5. package/.medusa/server/src/api/admin/ordinant/classifications/route.js +23 -0
  6. package/.medusa/server/src/api/admin/ordinant/classify-all/route.js +108 -0
  7. package/.medusa/server/src/api/admin/ordinant/dictionary/route.js +17 -0
  8. package/.medusa/server/src/api/admin/ordinant/generate-all-skus/route.js +31 -0
  9. package/.medusa/server/src/api/admin/ordinant/overview/route.js +69 -0
  10. package/.medusa/server/src/api/admin/ordinant/products/[id]/classify/route.js +18 -0
  11. package/.medusa/server/src/api/admin/ordinant/products/[id]/generate-skus/route.js +34 -0
  12. package/.medusa/server/src/api/admin/ordinant/products/[id]/overview/route.js +59 -0
  13. package/.medusa/server/src/api/admin/ordinant/stats/route.js +17 -0
  14. package/.medusa/server/src/api/admin/ordinant/status/route.js +52 -0
  15. package/.medusa/server/src/api/middlewares.js +14 -0
  16. package/.medusa/server/src/api/store/ordinant/preview/route.js +110 -0
  17. package/.medusa/server/src/api/store/ordinant/preview/validators.js +34 -0
  18. package/.medusa/server/src/lib/client.js +136 -0
  19. package/.medusa/server/src/modules/ordinant/index.js +14 -0
  20. package/.medusa/server/src/modules/ordinant/service.js +27 -0
  21. package/.medusa/server/src/storefront.d.ts +61 -0
  22. package/.medusa/server/src/storefront.js +61 -0
  23. package/.medusa/server/src/subscribers/product-upsert.js +52 -0
  24. package/.medusa/server/src/workflows/bulk-operations.js +59 -0
  25. package/.medusa/server/src/workflows/classify-product.js +80 -0
  26. package/.medusa/server/src/workflows/confirm-classification.js +20 -0
  27. package/.medusa/server/src/workflows/generate-skus.js +81 -0
  28. package/.medusa/server/src/workflows/hooks/validate-cart-completion.js +127 -0
  29. package/README.md +495 -0
  30. package/package.json +92 -0
package/README.md ADDED
@@ -0,0 +1,495 @@
1
+ # medusa-plugin-ordinant
2
+
3
+ Ordinant compliance for Medusa v2: AI-proposed product classification with
4
+ merchant attestation, and a deterministic, citation-bearing checkout gate.
5
+
6
+ > **Ordinant is a compliance information and automation service — not legal
7
+ > advice.** No attorney–client relationship is created by its use. Merchants
8
+ > attest to the final classification of their products.
9
+
10
+ ## Contents
11
+
12
+ 1. [What it does](#what-it-does)
13
+ 2. [Requirements](#requirements)
14
+ 3. [Installation](#installation)
15
+ 4. [Configuration](#configuration)
16
+ 5. [How it works](#how-it-works)
17
+ 6. [The admin dashboard](#the-admin-dashboard)
18
+ 7. [The per-product widget](#the-per-product-widget)
19
+ 8. [The checkout gate](#the-checkout-gate)
20
+ 9. [Buyer verification assertions](#buyer-verification-assertions)
21
+ 10. [Recommended: early compliance preview](#recommended-early-compliance-preview-progressive-checkout-ux)
22
+ 11. [Onboarding an existing catalog](#onboarding-an-existing-catalog)
23
+ 12. [Admin API reference](#admin-api-reference)
24
+ 13. [Store API reference](#store-api-reference)
25
+ 14. [Troubleshooting](#troubleshooting)
26
+ 15. [Local development](#local-development)
27
+
28
+ ## What it does
29
+
30
+ | Moment | What happens |
31
+ |---|---|
32
+ | Product created or updated | Variants are sent to Ordinant; an LLM proposes dictionary attributes with a confidence score |
33
+ | Merchant opens the product page | The **Ordinant compliance** widget shows each SKU's status; the merchant confirms or edits the proposal — recording their **attestation** |
34
+ | Merchant opens the Ordinant dashboard | A catalog-wide triage queue: an "illegal orders stopped" impact metric, and bulk SKU-generation + classification + confirmation |
35
+ | 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 |
36
+
37
+ Three properties worth knowing up front:
38
+
39
+ - **No storefront changes required for the gate.** It runs inside the backend's
40
+ cart completion workflow; failures surface through the standard checkout error
41
+ path every storefront already renders. (An optional early-preview UX can be
42
+ wired in, and recommended — see [Recommended: early compliance preview](#recommended-early-compliance-preview-progressive-checkout-ux).)
43
+ - **Fail closed, always.** Unclassified SKUs cannot sell. An unreachable
44
+ decision engine blocks checkout (configurable, see `failMode`). A wrong
45
+ "allow" never happens silently.
46
+ - **The plugin holds no compliance logic.** Decisions are made, logged, and
47
+ replayable on the Ordinant server; the plugin carries data and citations.
48
+
49
+ ## Requirements
50
+
51
+ - Medusa **v2.4+** (built and tested against 2.17)
52
+ - Node.js **20+**
53
+ - A running Ordinant deployment and a merchant API key
54
+ - Ordinant's catalog-classification module enabled server-side
55
+ (`ORDINANT_CLASSIFY_ENABLED=true` on the Ordinant deployment) if you want
56
+ automatic proposals — the widget and gate work without it, but proposals
57
+ must then be created via Ordinant's API directly
58
+
59
+ ## Installation
60
+
61
+ ### 1. Install the package
62
+
63
+ ```bash
64
+ npm install medusa-plugin-ordinant
65
+ # or: yarn add medusa-plugin-ordinant / pnpm add medusa-plugin-ordinant
66
+ ```
67
+
68
+ > **pnpm users:** ensure `@tanstack/react-query` matches the version used by
69
+ > `@medusajs/dashboard` (see Medusa's admin-customization docs) or the admin
70
+ > widget will fail to resolve its peer dependency.
71
+
72
+ ### 2. Register the plugin
73
+
74
+ In `medusa-config.ts`:
75
+
76
+ ```ts
77
+ module.exports = defineConfig({
78
+ projectConfig: {
79
+ // ... your existing config
80
+ },
81
+ plugins: [
82
+ {
83
+ resolve: "medusa-plugin-ordinant",
84
+ options: {
85
+ baseUrl: process.env.ORDINANT_BASE_URL,
86
+ apiKey: process.env.ORDINANT_API_KEY,
87
+ },
88
+ },
89
+ ],
90
+ })
91
+ ```
92
+
93
+ ### 3. Set environment variables
94
+
95
+ In the backend's `.env`:
96
+
97
+ ```bash
98
+ ORDINANT_BASE_URL=https://your-ordinant-deployment.example
99
+ ORDINANT_API_KEY=ord_live_...
100
+ ```
101
+
102
+ The API key is a **merchant** key issued by your Ordinant deployment
103
+ (`ORDINANT_API_KEYS` on the server). It never reaches the browser: the admin
104
+ widget talks to plugin-provided backend routes, which call Ordinant
105
+ server-side.
106
+
107
+ ### 4. Restart and verify
108
+
109
+ ```bash
110
+ npm run dev
111
+ ```
112
+
113
+ If `baseUrl` or `apiKey` is missing the plugin **does not crash** — it boots
114
+ into a degraded, un-activated state: the **Ordinant** dashboard shows an
115
+ [activation gate](#the-admin-dashboard) instead of data, and the checkout gate
116
+ treats the engine as unreachable (honoring `failMode`). A half-configured
117
+ install never takes down the backend.
118
+
119
+ To verify a *good* connection, open the **Ordinant** page in the admin sidebar:
120
+ the header's engine-health pill should read **"Engine connected · `<ruleset
121
+ version>`"** and your catalog should load.
122
+
123
+ ## Configuration
124
+
125
+ All plugin options:
126
+
127
+ | Option | Required | Default | Purpose |
128
+ |---|---|---|---|
129
+ | `baseUrl` | ✅ | — | Ordinant API base URL |
130
+ | `apiKey` | ✅ | — | Merchant bearer key |
131
+ | `timeoutMs` | — | `5000` | Per-request timeout; checkout must not hang |
132
+ | `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. |
133
+ | `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. |
134
+
135
+ ## How it works
136
+
137
+ ```
138
+ product.created / product.updated
139
+ │ (subscriber)
140
+
141
+ classifyProductWorkflow ──► POST {ordinant}/v1/catalog/classify
142
+ │ LLM proposes attributes + confidence
143
+
144
+ proposal stored on Ordinant (status: proposed — cannot sell yet)
145
+
146
+ ▼ merchant reviews in the product-page widget
147
+ POST /admin/ordinant/classifications/{sku}/confirm
148
+ │ attestation recorded (merchant is the classifier of record)
149
+
150
+ SKU is confirmed — checkout decisions now resolve its attributes
151
+
152
+ ▼ buyer completes checkout
153
+ completeCartWorkflow.hooks.validate
154
+ └─► POST {ordinant}/v1/decisions (bare SKUs + destination + buyer)
155
+ ALLOW → order places
156
+ CONDITIONAL/BLOCK → completion stops, citations in the error
157
+ ```
158
+
159
+ Classification failures (Ordinant down, module disabled, odd product data)
160
+ are logged and swallowed — catalog work is never disrupted. The SKU simply
161
+ stays unclassified, which the gate fails closed on.
162
+
163
+ ## The admin dashboard
164
+
165
+ A dedicated **Ordinant** page in the admin sidebar (its icon swaps to your logo
166
+ — see [Branding](#branding)) is the catalog-wide cockpit the per-product widget
167
+ can't give you. It's built as a *triage queue*: it surfaces what needs action
168
+ first, not a wall of everything.
169
+
170
+ - **Activation gate.** Until a valid API key is configured, the whole page is
171
+ blurred behind a lock card linking to your Ordinant sign-up — an un-activated
172
+ install looks intentional, not broken.
173
+ - **Welcome header + engine-health pill.** A greeting (by the signed-in admin's
174
+ first name) and a live status pill — **Engine connected · `<ruleset
175
+ version>`**, **Engine unreachable**, or **Not activated** — from
176
+ `GET /admin/ordinant/status`.
177
+ - **Impact hero — "Illegal orders stopped."** The headline metric, from
178
+ `GET /admin/ordinant/stats`: an animated count of blocked orders with a
179
+ **Day / Month / Year / All** toggle, a trend chart, and KPI cards (all-time
180
+ stopped, top blocked destination, stopped this year). Before the first block
181
+ it reads "You're protected — no illegal orders yet" rather than a bare zero.
182
+ - **Filter tiles.** Click to filter: **Needs attention** (the default —
183
+ everything actionable: needs-SKU + unclassified + to-review), **Needs SKU**,
184
+ **Unclassified**, **To review**, **Confirmed**, **All**. A caught-up queue
185
+ shows a calm "You're all caught up — N SKUs protected" state.
186
+ - **Search + pagination.** Filter by product title or SKU. The queue views
187
+ paginate product cards; **All** / **Confirmed** switch to a dense, paginated
188
+ table (editing a row there opens a side drawer) so hundreds of SKUs stay
189
+ scannable. Filter, search, and page are kept in the URL — shareable and
190
+ back-button friendly.
191
+ - **Bulk actions** (top-right, shown only when relevant):
192
+ - **Generate N SKUs** — assign readable, collision-free SKUs to every SKU-less
193
+ variant, then auto-classify them: one hands-off action with a live progress
194
+ bar.
195
+ - **Classify N** — classify the unclassified backlog in chunks.
196
+ - **Confirm N ready** — attest high-confidence proposals (≥ 85%) in one go.
197
+ Because every confirmation is a legal attestation, the dialog shows a
198
+ **category-grouped breakdown of exactly what you're attesting** ("212
199
+ accessories, 40 magazines, 12 handguns"), not a bare count.
200
+
201
+ Attributes are shown as plain-language chips (`Magazine · 30 rounds · non-NFA`),
202
+ never raw JSON. Typical onboarding: open the page → **Generate N SKUs** →
203
+ **Classify N** → **Confirm N ready** for the confident batch → review the handful
204
+ that need judgment with the dropdown editor.
205
+
206
+ ### Branding
207
+
208
+ Drop your company media in one place — `src/admin/components/brand-assets.tsx`:
209
+
210
+ ```ts
211
+ export const BRAND = {
212
+ name: "Ordinant",
213
+ logo: "", // square logo (data: URI or imported asset) → sidebar icon, gate, empty-state marks
214
+ wordmark: "", // optional horizontal wordmark → welcome header
215
+ website: "https://ordinant.com", // gate CTA target
216
+ }
217
+ ```
218
+
219
+ Set `logo` and the sidebar icon, activation gate, and empty-state marks all pick
220
+ it up; leave it empty to use the built-in shield. These are build-time constants,
221
+ so set them before building the plugin.
222
+
223
+ ## The per-product widget
224
+
225
+ On every product page (`product.details.after` zone):
226
+
227
+ - **Per-SKU status**: `unclassified`, `proposed · NN%` (hover for the model's
228
+ notes), or `confirmed` (hover for the attestation timestamp).
229
+ - **Generate SKUs** for SKU-less variants — the same helper as the dashboard.
230
+ - **Confirm** accepts the proposal as-is. **Edit** opens a **dropdown/checkbox
231
+ attribute editor generated from the compliance dictionary** — never raw JSON —
232
+ so the confirmed set is *your* assertion, corrected in a guided form.
233
+ - **Classify** (re-)submits the product's variants — use it for products created
234
+ before the plugin was installed, or after editing product data.
235
+ - Invalid attribute edits are rejected by Ordinant's dictionary validation and
236
+ shown inline — garbage can't be attested.
237
+
238
+ **Category-adaptive fields.** The editor shows only the attributes relevant to
239
+ the chosen category — pick **optic** and you won't see magazine capacity, action
240
+ type, or NFA class; pick **knife** and you get blade type and length. This is
241
+ built into the plugin (a category → fields map) and also honors the engine
242
+ dictionary's `applies_to` when present, so it's correct without waiting on a
243
+ dictionary change.
244
+
245
+ Re-classifying a SKU (e.g. after a product edit) resets it to `proposed`: a
246
+ stale attestation never survives changed product data.
247
+
248
+ ## The checkout gate
249
+
250
+ Consumes `completeCartWorkflow.hooks.validate`, which runs before any order
251
+ is created. Semantics:
252
+
253
+ - **Items**: every cart line is checked by its `variant_sku`. A line with
254
+ **no SKU** cannot be identified or gated, so it **fails closed** (blocks
255
+ checkout) by default — silently skipping it would be a fail-open hole on a
256
+ compliance gate. Give every sellable variant a SKU; or set
257
+ `skuMode: "allow"` if you knowingly sell unregulated SKU-less items and
258
+ accept they go ungated. (In Medusa every product has at least one variant,
259
+ but a variant's SKU is optional — this is about SKU presence, not variant
260
+ count.)
261
+ - **Bare-SKU decisions**: the gate sends empty attributes; Ordinant resolves
262
+ the merchant's *confirmed* classifications server-side. Proposed-but-
263
+ unconfirmed classifications are never used.
264
+ - **Destination**: from the cart's shipping address (`province` = US state).
265
+ A missing state blocks completion with a clear message. Non-US
266
+ destinations skip the gate entirely (Ordinant is US-only in v1) with a
267
+ warning log.
268
+ - **Outcomes**: `ALLOW` → order proceeds (the decision ID and ruleset
269
+ version are logged for audit). `BLOCK` / unmet `CONDITIONAL` → a
270
+ `MedusaError` stops completion; the message lists each problem SKU with
271
+ its requirement or prohibition **and the statute citations**. Storefronts
272
+ render this through their normal checkout error handling — the Next.js
273
+ starter shows it on the payment step with no modification.
274
+ - **Idempotency**: decisions are keyed `medusa-cart:{cart_id}`, so checkout
275
+ retries never double-log on the Ordinant side.
276
+ - **Unclassified SKUs** produce a distinct message ("product is not yet
277
+ classified for compliance") so merchants immediately know it's an
278
+ onboarding gap, not a legal block.
279
+
280
+ ## Buyer verification assertions
281
+
282
+ Conditional effects (age gates, FFL routing, permits) are satisfied by
283
+ verification *assertions* — Ordinant never receives identity documents.
284
+ Upstream integrations (BlueCheck/AgeChecker-style age verification, FFL
285
+ selection UIs) should write their results to `cart.metadata.ordinant_buyer`
286
+ before completion:
287
+
288
+ ```json
289
+ {
290
+ "age_verified": true,
291
+ "verified_age": 27,
292
+ "ffl_verified": true,
293
+ "sot_verified": false,
294
+ "permits": ["il_foid"],
295
+ "fulfillment_method": "ship"
296
+ }
297
+ ```
298
+
299
+ Until such an integration exists, conditional requirements simply block
300
+ completion with a message stating exactly what's missing (e.g. "route
301
+ shipment to a verified FFL [18 U.S.C. § 922(b)(3)]") — which is the correct
302
+ fail-safe default.
303
+
304
+ ## Recommended: early compliance preview (progressive checkout UX)
305
+
306
+ > **Strongly recommended for every storefront.** It's a few lines to wire, it
307
+ > can't break checkout (fail-open, backstopped by the mandatory gate), and it
308
+ > transforms the experience: the buyer learns their order can't ship to their
309
+ > state — *with the statute citation* — the instant they enter the address,
310
+ > right where they can fix it, instead of after they've entered a card at
311
+ > "Place Order". This is the difference between compliance that feels like a
312
+ > dead end and compliance that feels helpful.
313
+
314
+ The checkout gate above is the **authoritative, non-bypassable** enforcement
315
+ point: it runs server-side at cart completion and stops non-compliant orders no
316
+ matter what the storefront does. That is mandatory and needs zero storefront
317
+ work.
318
+
319
+ Surfacing a problem *earlier* — the moment a buyer picks a shipping state
320
+ instead of at "Place Order" — is the recommended UX layer on top. The plugin
321
+ exposes an advisory, **non-logging** preview for exactly this:
322
+
323
+ - **`POST /store/ordinant/preview`** (storefront, publishable key) — body
324
+ `{ "cart_id": "...", "destination"?: { "state", ... }, "buyer"?: { ... } }`.
325
+ It resolves the cart's items server-side; the destination comes from the
326
+ optional `destination` (a proposed, not-yet-saved address) or the cart's saved
327
+ shipping address. Returns what the gate *would* decide. It **never blocks and
328
+ fails open** — it is purely advisory.
329
+ - It proxies to Ordinant's `POST /v1/decisions/preview`, which is non-logging by
330
+ default so re-previews don't inflate anything — **with one deliberate
331
+ exception:** a **BLOCK** is recorded **once per cart** (deduped by an
332
+ idempotency key the store route sends). A buyer turned away at the address
333
+ step is a real "stopped illegal order," so it counts toward the merchant's
334
+ blocked-order metric — but repeat previews of the same cart never double-count,
335
+ and ALLOW/CONDITIONAL previews are never logged.
336
+
337
+ Two rules keep this safe:
338
+
339
+ 1. **The preview is advisory; the completeCart gate is the guarantee.** Client
340
+ checks are bypassable, so they can never be the enforcement point. Always
341
+ keep the gate — the preview only *complements* it.
342
+ 2. **Surface each requirement where its input appears.** The destination verdict
343
+ (PII-free) resolves as soon as the shipping state is known — the
344
+ highest-value early signal, and it fires *before* payment authorization, so
345
+ you never authorize a card you're about to block. Age/FFL requirements
346
+ surface at their own steps once those assertions exist.
347
+
348
+ ### Wiring it into your checkout
349
+
350
+ Two small steps — the same everywhere; only *where* you hook in differs.
351
+
352
+ **Step 1 — find where your checkout saves the shipping address.** That's the
353
+ action behind your "Continue to delivery" (or equivalent) button.
354
+
355
+ | Storefront | Hook point |
356
+ |---|---|
357
+ | Medusa **Next.js starter** | the `setAddresses` server action in `src/lib/data/cart.ts` |
358
+ | Any custom storefront | wherever you call `sdk.store.cart.update({ shipping_address })` (i.e. `POST /store/carts/{id}`) to save the address |
359
+
360
+ **Step 2 — check the proposed address *before* you save it, and stop if it's
361
+ blocked.** Import the helper the plugin ships:
362
+
363
+ ```ts
364
+ import { checkAddressCompliance } from "medusa-plugin-ordinant/storefront"
365
+
366
+ // in your address-submit handler, BEFORE you persist the address:
367
+ const block = await checkAddressCompliance(sdk, {
368
+ cartId,
369
+ destination: { state, country_code }, // the values the buyer just entered
370
+ })
371
+ if (block) {
372
+ return block // show `block`; do NOT save the address
373
+ }
374
+ await saveTheAddress() // your existing cart update
375
+ ```
376
+
377
+ - `sdk` is your Medusa JS SDK (anything with a `client.fetch`).
378
+ - `block` is a ready-to-display message with statute citations, or `null`.
379
+ - **Fail-open**: any error, or an unconfigured plugin, returns `null` — it can
380
+ never break checkout. Only a hard **BLOCK** returns a message; a `CONDITIONAL`
381
+ (age/FFL not gathered yet) returns `null` and proceeds to the next step.
382
+ - Checking the **proposed** `destination` *before* saving means a blocked
383
+ address is never written to the cart — a buyer who backs out and re-enters
384
+ checkout won't find a restricted address waiting.
385
+
386
+ **Display:** render the returned string wherever your form shows errors. In the
387
+ Next.js starter, `setAddresses` already returns its errors to an
388
+ `<ErrorMessage>`, so you just `return block` — no component change:
389
+
390
+ ```ts
391
+ // src/lib/data/cart.ts
392
+ import { checkAddressCompliance } from "medusa-plugin-ordinant/storefront"
393
+
394
+ export async function setAddresses(currentState: unknown, formData: FormData) {
395
+ try {
396
+ // ...parse the form into `data`...
397
+ const block = await checkAddressCompliance(sdk, {
398
+ cartId,
399
+ destination: {
400
+ state: String(formData.get("shipping_address.province") ?? ""),
401
+ country_code: String(formData.get("shipping_address.country_code") ?? ""),
402
+ },
403
+ })
404
+ if (block) return block // stops the redirect; shown by <ErrorMessage>
405
+ await updateCart(data) // save only when clear
406
+ } catch (e: any) {
407
+ return e.message
408
+ }
409
+ redirect(`/${countryCode}/checkout?step=delivery`)
410
+ }
411
+ ```
412
+
413
+ Prefer not to add the dependency? `checkAddressCompliance` is a single,
414
+ self-contained function — copy it from `src/storefront.ts` into your codebase
415
+ instead. Either way, anything you don't wire is still caught by the completeCart
416
+ gate; the preview is pure upside.
417
+
418
+ ## Onboarding an existing catalog
419
+
420
+ Products created **after** installation classify automatically. For the
421
+ existing catalog:
422
+
423
+ - Small catalogs: open each product and click **Classify**.
424
+ - Bulk: call Ordinant's `POST /v1/catalog/classify` directly with your
425
+ merchant key, in chunks of **~100 SKUs per request** (see the Ordinant API
426
+ reference for the payload). Then confirm in the widget, or via
427
+ `POST /v1/catalog/classifications/{sku}/confirm` for programmatic
428
+ confirmation of high-confidence proposals — remembering that confirmation
429
+ is your attestation.
430
+
431
+ **Classify everything, including non-regulated products** (t-shirts and
432
+ cleaning kits classify as `accessory`): the gate fails closed on
433
+ unclassified SKUs by design. An unclassified hoodie blocking checkout is an
434
+ onboarding gap; a misclassified suppressor shipping is a felony. The design
435
+ prefers the first failure.
436
+
437
+ ## Admin API reference
438
+
439
+ Routes the plugin adds to your Medusa backend (admin-authenticated;
440
+ they proxy to Ordinant server-side):
441
+
442
+ | Route | Purpose |
443
+ |---|---|
444
+ | `GET /admin/ordinant/overview` | Every store variant × classification state (incl. `needs_sku`) + summary counts |
445
+ | `GET /admin/ordinant/products/{id}/overview` | Per-product version of the above (the widget's display query) |
446
+ | `GET /admin/ordinant/dictionary` | The attribute dictionary the editor renders its dropdowns from |
447
+ | `POST /admin/ordinant/products/{id}/generate-skus` | Assign readable, collision-free SKUs to a product's SKU-less variants |
448
+ | `POST /admin/ordinant/classify-all` | Classify the next chunk of unclassified SKUs; returns `remaining` so the caller loops |
449
+ | `POST /admin/ordinant/classifications/bulk-confirm` | Attest a batch of proposals as-is; body `{"skus": [...]}` |
450
+ | `GET /admin/ordinant/classifications?skus=a,b,c` | Classification records, optionally filtered to specific SKUs (the widget's display query) |
451
+ | `POST /admin/ordinant/classifications/{sku}/confirm` | Record the merchant's attestation; body `{"attributes": {...}}` |
452
+ | `POST /admin/ordinant/products/{id}/classify` | (Re-)classify a product's variants on demand |
453
+ | `GET /admin/ordinant/status` | Readiness for the dashboard: `configured`, `engine_reachable`, `ruleset_version` |
454
+ | `GET /admin/ordinant/stats` | Blocked-order metrics ("stopped illegal orders") for the impact hero |
455
+
456
+ ## Store API reference
457
+
458
+ Routes the plugin adds for the **storefront** (publishable-key auth, guest-safe):
459
+
460
+ | Route | Purpose |
461
+ |---|---|
462
+ | `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](#recommended-early-compliance-preview-progressive-checkout-ux). |
463
+
464
+ ## Troubleshooting
465
+
466
+ | Symptom | Cause / fix |
467
+ |---|---|
468
+ | 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 |
469
+ | 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 |
470
+ | 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) |
471
+ | Every checkout is blocked with "not yet classified" | Working as designed — confirm classifications for every sellable SKU, including non-regulated ones |
472
+ | 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 |
473
+ | 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` |
474
+ | 401s from Ordinant in logs | Wrong or rotated merchant key |
475
+ | Gate never fires | Cart items have no `variant_sku`, or the destination isn't US |
476
+
477
+ ## Local development
478
+
479
+ This plugin lives in a workspace inside the `ordinant-test` monorepo:
480
+
481
+ ```bash
482
+ # build the plugin (server + admin extensions)
483
+ cd plugins/medusa-plugin-ordinant && npm run build
484
+
485
+ # run the backend against it (workspace-linked automatically)
486
+ cd apps/backend && npm run dev
487
+
488
+ # live-reload plugin development (publishes to the local yalc registry)
489
+ cd plugins/medusa-plugin-ordinant && npm run dev
490
+ ```
491
+
492
+ Point `ORDINANT_BASE_URL` at a local engine (`go run ./cmd/ordinantd` in the
493
+ Ordinant repo, default `http://localhost:8080`) for a fully local loop.
494
+
495
+ To publish: `npm publish` (the `prepublishOnly` hook builds `.medusa/server`).
package/package.json ADDED
@@ -0,0 +1,92 @@
1
+ {
2
+ "name": "medusa-plugin-ordinant",
3
+ "version": "0.1.0",
4
+ "description": "Ordinant compliance for Medusa v2: catalog classification with merchant attestation and a deterministic, citation-bearing checkout gate. Compliance information and automation - not legal advice.",
5
+ "author": "Ordinant (https://ordinant.com)",
6
+ "license": "MIT",
7
+ "homepage": "https://ordinant.com",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Kaelbroersma/medusa-ordinant-plugin.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://ordinant.com"
14
+ },
15
+ "files": [
16
+ ".medusa/server"
17
+ ],
18
+ "exports": {
19
+ "./package.json": "./package.json",
20
+ "./workflows": "./.medusa/server/src/workflows/index.js",
21
+ "./.medusa/server/src/modules/*": "./.medusa/server/src/modules/*/index.js",
22
+ "./modules/*": "./.medusa/server/src/modules/*/index.js",
23
+ "./providers/*": "./.medusa/server/src/providers/*/index.js",
24
+ "./storefront": {
25
+ "types": "./.medusa/server/src/storefront.d.ts",
26
+ "import": "./.medusa/server/src/storefront.js",
27
+ "require": "./.medusa/server/src/storefront.js",
28
+ "default": "./.medusa/server/src/storefront.js"
29
+ },
30
+ "./*": "./.medusa/server/src/*.js",
31
+ "./admin": {
32
+ "import": "./.medusa/server/src/admin/index.mjs",
33
+ "require": "./.medusa/server/src/admin/index.js",
34
+ "default": "./.medusa/server/src/admin/index.js"
35
+ }
36
+ },
37
+ "keywords": [
38
+ "medusa",
39
+ "plugin",
40
+ "medusa-plugin",
41
+ "medusa-plugin-other",
42
+ "medusa-v2",
43
+ "compliance",
44
+ "checkout",
45
+ "firearms",
46
+ "age-verification",
47
+ "ordinant"
48
+ ],
49
+ "scripts": {
50
+ "build": "medusa plugin:build",
51
+ "postbuild": "tsc src/storefront.ts --declaration --emitDeclarationOnly --outDir .medusa/server/src --skipLibCheck --target ES2020 --module ESNext --moduleResolution node",
52
+ "dev": "medusa plugin:develop",
53
+ "lint": "medusa lint",
54
+ "prepublishOnly": "npm run build"
55
+ },
56
+ "devDependencies": {
57
+ "@medusajs/admin-sdk": "2.17.2",
58
+ "@medusajs/cli": "2.17.2",
59
+ "@medusajs/eslint-plugin": "2.17.2",
60
+ "@medusajs/framework": "2.17.2",
61
+ "@medusajs/icons": "2.17.2",
62
+ "@medusajs/medusa": "2.17.2",
63
+ "@medusajs/test-utils": "2.17.2",
64
+ "@medusajs/ui": "4.1.19",
65
+ "@swc/core": "^1.7.28",
66
+ "@types/node": "^20.0.0",
67
+ "@types/react": "^18.3.2",
68
+ "@types/react-dom": "^18.2.25",
69
+ "eslint": "^9.0.0",
70
+ "jiti": "^2.0.0",
71
+ "prop-types": "^15.8.1",
72
+ "react": "^18.2.0",
73
+ "react-dom": "^18.2.0",
74
+ "ts-node": "^10.9.2",
75
+ "typescript": "^5.6.2",
76
+ "vite": "^5.2.11",
77
+ "yalc": "^1.0.0-pre.53"
78
+ },
79
+ "peerDependencies": {
80
+ "@medusajs/admin-sdk": "2.17.2",
81
+ "@medusajs/cli": "2.17.2",
82
+ "@medusajs/framework": "2.17.2",
83
+ "@medusajs/icons": "2.17.2",
84
+ "@medusajs/medusa": "2.17.2",
85
+ "@medusajs/test-utils": "2.17.2",
86
+ "@medusajs/ui": "4.1.19"
87
+ },
88
+ "engines": {
89
+ "node": ">=20"
90
+ },
91
+ "packageManager": "npm@11.6.1"
92
+ }