@illumify/sdk
v0.1.0
Published
Framework-agnostic data library for Illumify Storefront themes
Readme
@illumify/sdk
The data library for Illumify Storefront themes. Framework-agnostic, zero runtime
dependencies, plain fetch.
A theme is an immutable uploaded ZIP of HTML, JavaScript, CSS and static assets. The ERP
serves it as its own browser document, injects window.IllumifyStorefront before your
code runs, and answers your data calls on the read-only Catalog API v1. Pricing is
resolved entirely server-side.
import { getSession, createItemPager, recommendedOffer, offerPrice } from "@illumify/sdk";
const session = await getSession();
const pager = createItemPager({ sort: "nameAsc" });
await pager.loadMore();
for (const item of pager.items) {
const offer = recommendedOffer(item);
const price = offer && offerPrice(offer);
render(item.name, price?.kind === "priced" ? price.effectiveUnitPrice : null, session.currencyCode);
}What this package is for
There are four GETs. You could call them with fetch. Three things would go wrong,
identically, and none of them would look broken until a catalogue changed under a live
shopper — so those three are what this package actually contains. Everything else is a
typed wrapper.
| | The trap | What the package does |
| --- | --- | --- |
| 409 | It reads like a failure. It is an instruction to restart pagination and throw away what you have. | createItemPager owns your list and does both. CatalogRevisionChangedError is a distinct class for hand-rolled paging. |
| Cursors | 15-minute HMAC tokens pinned to the query. Changing a filter mid-walk kills them. | The pager pins its query at construction. A new query is a new pager. |
| Offers | Missing money is null, not 0; the recommendation must not be inferred from price. | offerPrice returns a union you must narrow. recommendedOffer never guesses. |
And one rule no type can carry on its own: a shopper with more than one eligible
facility has none selected for them, so every price is null until your theme asks
which facility they are buying for. Read that before you plan a
feature.
Read Paging without duplicating the catalogue and The offer model before writing a product grid. They are the two sections that will save you an afternoon.
Provenance: no response body has been seen yet
The catalog routes are reachable on QA, and as of 2026-08-20 some of this package's
behavioural claims are measured against them. What is not measured is any
response shape: the QA storefront has no theme assigned, so every read is refused
with the contract's generic empty-bodied 404 before it reaches a catalogue. What that
means for you:
- Every wire shape — every field name, every enum member — is from generated
contracts (
Main/Functions/Contracts/StorefrontAccess/Generated/), checked in both directions bynpm run check:contracts: a field this package declares that the server does not send, and a field the server sends that this package does not declare, both fail the build. - Measured so far: that the same empty-bodied
404answers every ownership failure and cannot be told apart from an unknown storefront; thatcache-controlisprivate, no-storeon the session read andprivate, no-cacheon the three catalogue reads; and thatcursorandsortare refused with400 InvalidQueryongetFiltersandgetItemrather than ignored. - Everything else — the offer projection, cursor lifetime, the
409, the503, the facility-suppression rule — is from source, read out ofMain/Functions/Service/StorefrontAccess/.
Treat the field names as solid and any behaviour not listed above as unverified. If
something surprises you on the wire, the wire wins — and please update AGENTS.md with a
measured marker when it does.
Read this before you plan a feature
Four things about this platform are not obvious and are discovered late. None of them is something the SDK can work around.
Your theme cannot call a third-party API, and cannot embed an iframe
The served document's CSP is default-src 'self', with connect-src 'self',
frame-src 'none', frame-ancestors 'none', object-src 'none' and worker-src 'none'.
img-src, font-src and media-src additionally allow data: and blob:, but no
remote host at all.
So: no analytics beacon, no external font CDN, no map embed, no third-party checkout widget, no Web Worker. Anything your theme needs at runtime has to be inside the theme ZIP or come from the storefront's own origin. Plan the design around that rather than discovering it at upload.
Product images are a contract field — you do not have to invent them
CatalogItem.primaryImage and CatalogItemDetail.imageList carry real ERP product media,
served from the storefront's own origin under
/public/storefront-product-media/{documentId}/{contentKey} with immutable public
caching. Render image.url directly.
Two consequences. Media comes only from real ERP document mappings, so it is never
inferred from a filename in your theme — there is no naming convention to follow. And
config.assetBaseUrl serves your theme's files, which is a different thing; the
injected <base href> already points there, so a relative src in your HTML resolves
correctly with no help from this package.
Identified does not mean discounted
session.shopperContext === "Identified" means the server knows who is asking. It does
not mean this shopper has negotiated prices.
On a buyer's first login the ERP shows a one-time onboarding form capturing a facility licence, and matches that licence against this seller's customer list. A unique match prices for that customer. No match gives public, base pricing — and there is no default pricing segment to fall into. More than one match fails closed.
session.shopperContext; // "Identified" — we know who you are
session.pricingContext; // "Public" — and you are still on base pricingSo pricingContext is the field that answers the pricing question, and
shopperContext is not. Never render "your price" off shopperContext. Copy that stays
true: signing in lets the seller apply existing customer pricing where the licence
matches.
A shopper with two facilities has no prices until they pick one
This is the one change most likely to be discovered as a bug report rather than read here, because nothing throws.
Buyer facilities hang off a resolved customer, so session.facilityList is empty for an
anonymous shopper and needs no UI. With exactly one eligible facility the server
selects it for you and prices normally — also nothing to build. With more than one,
the server selects none, because guessing which facility a shopper is buying for is
guessing at their prices. The catalogue still comes back, and it comes back
deliberately unpriced:
| Every offer | Every item |
| --- | --- |
| unitPrice: null, effectiveUnitPrice: null | available: false |
| discountPercent: 0, availableQuantity: null | offers still listed, still described |
| available: false, orderabilityCode: "Unavailable" | msrp and potency unaffected |
Nothing is out of stock and nothing has failed. The question has not been asked yet.
So a price grid rendered before a facility is chosen is a priced-looking page with nothing priced on it, and no error anywhere to explain why. Ask first:
const session = await getSession();
if (session.facilityList.length > 1) {
const { customerFacilityKey } = await chooseFacility(session.facilityList);
const pager = createItemPager({ customerFacilityKey });
}customerFacilityKey is opaque — send it back exactly as received, to getItems,
getItem, getFilters and the platform's cart methods alike. Never parse one, derive one
or build one; a key the server cannot decode, or one from another site or customer, is the
generic empty 404.
You may also select several validated facilities and render them as columns — one
pager per key. That is two walks, not one walk re-pointed: the key is bound into the
cursor, so a facility is part of the query rather than a setting on top of it. Each
offer's own customerFacilityKey tells you which column it belongs in.
There is exactly one refusal shape, and it tells you nothing
404 with an empty body. An unknown or inactive slug, a storefront with no assigned
theme, a revoked or rotated customer-link token, an expired preview capability, and a
skuId outside this catalogue are indistinguishable — the API returns the same
generic 404 for every ownership failure on purpose, so that a probe cannot tell them
apart.
There is no JSON envelope, so error.body is undefined. Do not write code that branches
on a 404's cause; it cannot be known. Render one "not available" state.
The injected browser API
The document renderer writes this into <head> before your code runs, along with a
<base href> pointing at your theme's asset root:
window.IllumifyStorefront = Object.freeze({
version: 2,
config: Object.freeze({
accessMode, // "Anonymous" | "CustomerLink" | "SignedInAccount" | "Preview"
documentBaseUrl, // root-relative path this document is served under
apiBaseUrl, // root-relative catalog API root — what this package reads
assetBaseUrl, // your theme's files
shopperContext, // "Anonymous" | "Identified"
themeKey,
}),
navigate,
login, // may be null
logout, // may be null
routeUrl,
getCart, // absent on preview
replaceCart, // absent on preview
prepareCheckout, // absent on preview
checkout, // absent on preview
});This package does not type that global, and does not re-export any of it. The ambient
declaration lives in illumify-cli's theme scaffold, by the owner's decision — that
is where the types for all of the above come from. Two declarations of one platform API
is one more than can stay in agreement.
apiBaseUrl is the server's decision about who you are
This is the single most important thing in the README, because getting it wrong is silent.
The API lives under the authority segment, and four shapes reach a theme:
/{env}/services/main/storefront/{slug}/api
/{env}/services/main/storefront/{slug}/c/{customerLinkToken}/api
/{env}/services/main/storefront/{slug}/_account/api
/{env}/services/main/storefront/{slug}/preview/{capability}/apiThe customer-link token is the credential. There is no storefront cookie and no
shopper session anywhere in this contract. So a theme that recomposed a catalog URL from
config.slug would strip the token and silently move an identified shopper onto public
pricing — nothing would throw, the storefront would just quote the wrong prices, and only
to the customers who came in by link.
Which is why this package builds no URL. It appends /session, /items,
/items/{skuId} or /filters to config.apiBaseUrl and derives nothing else: no origin,
no environment segment, no service prefix, no slug, no token, and no read of location.
npm run check:no-url-assembly enforces that on src and dist.
The practical upside: your theme is written once. All four endpoints exist under all
four authorities with identical response shapes, including preview, so there is no
accessMode branching to write for data access.
Navigation, login and logout are not re-exported
The platform injects navigate, login and logout, and honours three DOM hooks with
no theme JavaScript at all:
<a data-illumify-storefront-route="/products">Products</a>
<button data-illumify-storefront-login>Login</button>
<button data-illumify-storefront-logout>Logout</button>Route anchors are rewritten after DOMContentLoaded into real authority-preserving
hrefs, so ordinary click, copy-link and open-in-new-tab keep the active customer-link or
account context. That is behaviour a wrapper in this package could not reproduce and could
only contradict — and a third spelling of one platform call is one that can disagree with
the injected one. So the SDK exports none of it.
Two asymmetries worth knowing before you call the methods yourself (both from source):
- They can be
null, not absent.loginisnullon a preview document, on an already-signed-in one, and on a storefront with sign-in disabled.logoutisnullunless the document is a customer-link or account one. Guard with?.. - They are not symmetrical.
loginnavigates and returns nothing.logouton a customer-link document isasync: it POSTs, throws a plainError("Storefront logout failed")on a non-2xx, then navigates. The delegated click handler discards that promise, so a failed logout from a button is an unhandled rejection and nothing on screen. Call it yourself andcatchif you want to show the shopper something.
One more, from session.accessPolicy: invite administration and claiming are not
implemented, so signInRequiresInvite fails closed. Treat
signInEnabled && signInRequiresInvite as "sign-in is not available yet" rather than
rendering a login button that cannot succeed.
The cart and the checkout live on the runtime, deliberately
getCart, replaceCart, prepareCheckout and checkout are on
window.IllumifyStorefront, not in this package, and that is the design rather than work
left undone.
Each of them already carries this document's authority — anonymous, customer-link or
account — because the renderer closed over the same apiBaseUrl this package reads, and
each sends the X-Illumify-Storefront-Request header the cart handler requires on a
write. Wrapping them here would mean reconstructing both, which is the same authority
mistake check:no-url-assembly exists to prevent — except on the lane where getting it
wrong writes a Sales Order instead of showing a wrong price. So the read half is a package
and the write half is the platform's. Call them directly:
const cart = await window.IllumifyStorefront.getCart();
await window.IllumifyStorefront.replaceCart({
cartVersion: cart.cartVersion, // null on the first write
selectionList: [
{ offerId: offer.offerId, quantity: 2, observedOfferRevision: offer.offerRevision },
],
});Three things to know before you do:
- A
PUTis a full replace, not a merge, guarded by the opaquecartVersion. observedOfferRevisioncomes off the offer you rendered. A stale one answers409 PricingChangedwith the refreshed cart and no mutation — that is the point of it, so read it from the offer the shopper actually saw rather than refetching first.- A preview document has none of the four. They are absent, not
null, so test withtypeof api.getCart === "function"— the opposite of howloginandlogoutbehave.
The cart contract itself belongs to SalesOrderManager and is not typed here. What this
package guarantees is that the two values a cart line needs, offerId and offerRevision,
are on every offer it returns.
The four reads
getSession(options?): Promise<CatalogSession>
getItems(query?, options?): Promise<CatalogItemsResponse>
getItem(skuId, query?, options?): Promise<CatalogItemDetailResponse>
getFilters(query?, options?): Promise<CatalogFiltersResponse>Every response passes through untransformed — keys, casing and nulls exactly as the
server sent them. options is { signal } for standard cancellation.
There is no namespace object. The old package had shopper.* and erp.* because it had
two lanes to tell apart; with one, a namespace buys nothing.
getSession
GET {apiBaseUrl}/session, served private, no-store.
Worth calling even though config already carries the slug, the theme key and the shopper
context: this adds pricingContext, currencyCode, accessPolicy and facilityList, and
it describes the request the server just answered rather than the document it served
earlier. The last of those can change what you have to render — see below.
getItems
GET {apiBaseUrl}/items?cursor&search&categoryId&brandId&inventoryCategoryId&classId&tagId&sort
The SDK's plural query members map onto the server's repeatable singular parameters —
categoryIds: [4, 9] becomes categoryId=4&categoryId=9. Repeated ids inside one group
are an OR; different groups are an AND. Empty arrays are dropped rather than sent.
The server chooses the delivery mode: a small enough catalogue comes back "complete" in
one response, a larger one as a fixed keyset window with a nextCursor. You do not ask for
either. The signal for "there is more" is a non-null nextCursor, not deliveryMode.
search is capped at 400 characters — measured by the server after Unicode FormKC
normalisation, whitespace collapsing and upper-casing, and answered 400 InvalidQuery
past that. This package refuses rather than truncating or normalising: it measures the
length the way the server does and throws a RangeError before anything is sent. A
truncated search would return results for text your shopper did not type, and normalising
here would mean shipping a second copy of a server rule that is free to drift from it. In
practice put a maxlength on the input — 400 characters is far past any real search box.
getItems also returns the facets on response.filters, so a first paint needs one
request rather than two.
An empty items with nextCursor: null is a legitimate answer: a storefront whose profit
centre has no live-priced SKUs has an empty catalogue, not a broken one.
getItem
GET {apiBaseUrl}/items/{skuId}. Wrapped in an envelope, like the list — the item is on
response.item. Adds description and the full imageList.
CatalogItemQuery has one member, customerFacilityKey. The detail read binds the same
query as the list, but the facet ids and search are parsed and then ignored, while
cursor and sort are rejected with 400 InvalidQuery — so neither is in the type.
A non-positive or non-integer skuId throws a RangeError synchronously rather than
becoming a 404 you would read as "no such product".
getFilters
GET {apiBaseUrl}/filters. The ids are what getItems takes. Counts reflect the query
you pass, so passing the applied filters gives co-varying counts and passing nothing gives
catalogue-wide ones.
CatalogFiltersQuery has no cursor and no sort — this endpoint rejects both with
400 InvalidQuery rather than ignoring them, so their absence from the type turns a
runtime refusal into a compile error.
Two known-empty facets, both because Main has no authoritative source yet, and neither a
bug to work around: tags is always empty and a requested tagId matches nothing;
currencyCode is always "USD".
Paging without duplicating the catalogue
Cursors are versioned HMAC tokens with a 15-minute lifetime, pinned to the schema, the
site, an opaque pricing-context digest, the catalogue revision, the canonical query and
sort, the delivery mode, the window size, and the last page's final sort value and
skuId. They are keyset positions, not page numbers, and they are opaque: never parse,
construct or persist one.
Three things go wrong if you hold one yourself:
- Appending after a
409. Catch it, refetch page one, append — and the start of the catalogue renders twice, with prices that may have just changed. Nothing looks broken. - Keeping a cursor across a query change.
400 InvalidCursor, or a walk that silently stops if you swallow it. - Two requests with the same cursor. A double-clicked "load more" appends the same window twice, with no error anywhere.
createItemPager removes all three rather than guarding against them. It owns the
accumulated list, so you render pager.items and never append — which is what makes a
restart harmless instead of duplicating. It pins the query at construction. And it
serialises loadMore(), so concurrent calls share one request.
import { createItemPager } from "@illumify/sdk";
const pager = createItemPager({ search: "gelato", sort: "priceAsc" });
loadMoreButton.onclick = async () => {
const page = await pager.loadMore();
render(pager.items); // never `items.push(...)`
loadMoreButton.hidden = !page.hasMore;
if (page.restarted) {
toast("Prices changed — the list was refreshed.");
}
};| Member | |
| --- | --- |
| query | frozen copy, cursor discarded. Changing a filter means a new pager. |
| items | every item so far, in server order. New array identity on each change. |
| response | the latest response, or undefined before the first loadMore() |
| hasMore | true before the first load; false once the cursor runs out |
| restarts | how many restarts have been absorbed |
| loadMore(options?) | next page; idempotent no-op once exhausted |
| reset() | throw away items and cursor, keep the query |
A restart is absorbed on 409 CatalogRevisionChanged and on 400 InvalidCursor —
both mean "your cursor is dead, start over". A 404, a 400 InvalidQuery and a 503 are
passed straight through, because a restart cannot fix them and retrying would turn one
clear error into two slow ones. After maxRestarts (default 2) the error is rethrown
rather than looping.
If you page by hand anyway
import { getItems, CatalogRevisionChangedError, CatalogCursorRejectedError } from "@illumify/sdk";
try {
const page = await getItems({ ...query, cursor });
items.push(...page.items);
cursor = page.nextCursor ?? undefined;
} catch (error) {
if (error instanceof CatalogRevisionChangedError) {
items.length = 0; // ← the half everyone forgets
cursor = undefined;
} else if (error instanceof CatalogCursorRejectedError) {
// Your query changed under the cursor. A new query is a new walk.
cursor = undefined;
} else {
throw error;
}
}The distinct classes exist for exactly this: catching IllumifyApiError is not enough to
act on a 409, because restarting is a different action from showing an error.
One thing the 409 cannot tell you: the server maps its internal CursorExpired and
CatalogRevisionChanged to the same response body, so "the shopper left the tab open"
and "a price moved" are indistinguishable. The recovery is identical either way, which is
why the contract collapses them — but do not write a message claiming one cause.
The offer model
An offer is
StorefrontSite + skuId + batchId? + unitOfMeasureId + customerFacilityId? + stockCompanyId.
So different units of measure, item-versus-batch grains, buyer facilities and stock
sources are separate offers on the same SKU, not variants of one price. A SKU with an
each and a case UOM and two batches has more than two offers, and the numbers on them are
unrelated by design: UOM conversions affect quantities only, and the server never
multiplies or divides a price to manufacture another UOM's price. Neither should you.
The last two members of that identity never reach the browser as ids. What arrives is:
| Field | |
| --- | --- |
| customerFacilityKey | opaque; the facility this offer is priced for, null when it has none — every offer an anonymous shopper sees |
| stockCompanyKey | opaque; the source it ships from, always present |
| sourceLabel | that source's company name, for display, null when unknown |
| isDefault | whether the server ordered this offer first |
The two keys are opaque: compare them, group by them, hand them back — never parse
one, and never compose one. sourceLabel is a label, not an identity; two companies can
share a name, so group by stockCompanyKey.
Three tokens, three lifecycles
| | What it is | When it changes |
| --- | --- | --- |
| offer.offerId | merchandise identity: versioned, integrity-protected, site-scoped | never with price or shopper context |
| offer.offerRevision | context-bound change detection — not a quote | price, discount, conversion, availability, expiry, orderability |
| response.revision | the whole read model's dependency state | anything the catalogue depends on |
offerId is the only one you ever hand back to the server. Do not parse it, and do not
compose one from skuId, batchId, unitOfMeasureId, a facility key or a stock company
key — the browser may select only an opaque offer, which is why this package ships no
helper that would build one.
The recommendation comes from the server
const offer = recommendedOffer(item); // CatalogOffer | undefinedIt matches item.recommendedOfferId against offer.offerId and returns nothing else. It
does not fall back to the first offer, the cheapest offer, or the largest pack — that
would be exactly the price inference the contract forbids, and it agrees with the server
just often enough that nobody would notice the days it does not. undefined is a real
answer: render "choose a size", not a default.
The two price sorts order on this offer, and put missing or unavailable prices last — so rendering it shows the number the list was sorted by.
offer.isDefault is not a second opinion to weigh against it. It marks the offer the
server ordered first, and the two can disagree in exactly one direction: when a SKU has
offers but the server merchandised none of them, recommendedOfferId falls back to the
first orderable offer while every isDefault stays false. Render the named id.
Missing money is null, never 0
const price = offerPrice(offer);
if (price.kind === "unpriced") {
label.textContent = "Call for pricing";
} else {
label.textContent = format(price.effectiveUnitPrice, session.currencyCode);
}offerPrice returns a discriminated union so that price.effectiveUnitPrice does not
exist until the "unpriced" case is handled — which means ?? 0 never gets written, and
an unpriced, non-orderable offer never renders as free. A legitimate zero price is 0 and
stays "priced".
unitPrice is gross and discountPercent is separate; they are deliberately not
collapsed. effectiveUnitPrice is the server-rounded display net. All three come from the
shared Live Pricing calculator, which owns commercial rounding — project them, do not
recompute them. Recomputing net from gross and discount in JavaScript will disagree at
the cent, and the server's answer is the one an order gets written with.
available on both the item and the offer is server-decided (an item is available when at
least one offer is orderable). Do not re-derive it. Unavailable but catalogue-eligible SKUs
stay visible on purpose, so render the unavailable state rather than filtering them out.
availableQuantity is advisory and not reserved.
Errors
Every failure throws. The server body is carried through as-is; the SDK reshapes nothing,
never touches window.location, never redirects, and never retries.
import { IllumifyApiError, CatalogRevisionChangedError, CatalogCursorRejectedError } from "@illumify/sdk";CatalogRevisionChangedError and CatalogCursorRejectedError both extend
IllumifyApiError, so a generic catch still works.
error.status; // HTTP status; 0 when no response arrived
error.code; // the catalog API's own code, or undefined
error.body; // the server's body exactly as received; undefined on a 404| Status | code | Meaning |
| --- | --- | --- |
| 404 | — | every ownership failure, indistinguishable, empty body |
| 409 | CatalogRevisionChanged | restart pagination and discard — also covers cursor expiry |
| 400 | InvalidCursor | the cursor is for a different query, site or pricing context |
| 400 | InvalidQuery | a bad id, an unknown sort, or cursor/sort sent to getFilters or getItem |
| 503 | CatalogCapacityExceeded | past the catalogue's hydration bounds — not transient; narrow the query |
There is no onAuthExpired hook. The old package had one because its authenticated lane
needed a global reaction to a 401. This contract has no such state: account identity is
recomputed per request, and a catalog refusal is a 404 you cannot diagnose — a hook
firing on that would be noise.
What this package deliberately does not have
An absence that is explained reads as a decision; an absence that is not reads as a missing feature. So:
- No
login,logoutornavigate. The platform injects them and owns them; see above. - No
window.IllumifyStorefronttype. That declaration isillumify-cli's. - No cart and no checkout wrappers. The platform injects
getCart,replaceCart,prepareCheckoutandcheckoutalready carrying this document's authority; see above for why re-exporting them would be the one authority mistake with a Sales Order behind it. A cart line is{ offerId, quantity, observedOfferRevision }and both values come off an offer these reads returned. - No facility helper.
session.facilityListis a list andcustomerFacilityKeyis a string you pass through. There is nothing to resolve, validate or cache — the server answers404to a key it does not accept, and any local check would be a guess about a decision only the server makes. - No seller operations. Themes, assignments, customer links and previews are managed
through
MainStorefrontAccessby the ERP UI. A theme has no business calling them and this package does not type them. - No URL builder, no
assetUrl, no origin resolution. See above; the injected<base href>already handles relative asset paths in your HTML. - No image fallback helper. Product images are a contract field now.
- No price formatting.
currencyCodeis on the response;Intl.NumberFormatis in the browser.
Development
npm install
npm run verify # typecheck, unit tests, contract check, build, and all four guards
npm run build
npm run testFour guards, each enforcing a rule that a code review can confirm once but not on every commit:
npm run check:contracts— every declared field againstFunctions/Contracts/StorefrontAccess/Generated/Dc/Catalog*.cs, in both directions, plus the enum members, the sort values and the query parameter names. NeedsILLUMIFY_BACKEND_REPOpointing at a Main clone; it falls back togit showonorigin/QA23.20260430.0800, overridable withILLUMIFY_BACKEND_REF. With no read yet answering a body, this is still the only thing standing between the types and the wire.npm run check:no-url-assembly— no origin, host, slug, token or route segment is composed anywhere insrcordist.npm run check:no-credentials— nothing that could carry a key, insrcordist.npm run check:no-customer-data— no real tenant slug, id, account or internal hostname indistor this README.dist/index.d.tsis what a theme author's editor shows, so a doc comment naming one seller ships that seller's identity to every other one.
npm run build
STOREFRONT_API_BASE_URL="https://host/qa/services/main/storefront/my-slug/api" npm run smokenpm run smoke runs the built output under Node, so it tests the mapping this package
ships rather than a hand-written request that would agree with itself. It takes a base URL
rather than a slug, because the SDK reads config.apiBaseUrl and composes nothing else —
passing a slug would make the script build the URL the package refuses to build, and then
test its own guess. Take the value from a served document's injected config, or from
PreviewGet's previewUrl. It must be absolute here: a served document injects a
root-relative path, which has no document to resolve against under Node.
It cannot pass in full today, against QA. The routes answer, but the QA storefront has
no theme assigned, so the four steps that need a catalogue get the contract's generic
404. The two that do not — getFilters refusing cursor and sort, and getItem
answering an empty-bodied 404 for an unknown SKU — pass against the wire. Point it at a
storefront with a theme and the rest have something to read.
