npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 by npm 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 404 answers every ownership failure and cannot be told apart from an unknown storefront; that cache-control is private, no-store on the session read and private, no-cache on the three catalogue reads; and that cursor and sort are refused with 400 InvalidQuery on getFilters and getItem rather than ignored.
  • Everything else — the offer projection, cursor lifetime, the 409, the 503, the facility-suppression rule — is from source, read out of Main/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 pricing

So 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}/api

The 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. login is null on a preview document, on an already-signed-in one, and on a storefront with sign-in disabled. logout is null unless the document is a customer-link or account one. Guard with ?..
  • They are not symmetrical. login navigates and returns nothing. logout on a customer-link document is async: it POSTs, throws a plain Error("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 and catch if 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 PUT is a full replace, not a merge, guarded by the opaque cartVersion.
  • observedOfferRevision comes off the offer you rendered. A stale one answers 409 PricingChanged with 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 with typeof api.getCart === "function" — the opposite of how login and logout behave.

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:

  1. 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.
  2. Keeping a cursor across a query change. 400 InvalidCursor, or a walk that silently stops if you swallow it.
  3. 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 | undefined

It 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, logout or navigate. The platform injects them and owns them; see above.
  • No window.IllumifyStorefront type. That declaration is illumify-cli's.
  • No cart and no checkout wrappers. The platform injects getCart, replaceCart, prepareCheckout and checkout already 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.facilityList is a list and customerFacilityKey is a string you pass through. There is nothing to resolve, validate or cache — the server answers 404 to 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 MainStorefrontAccess by 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. currencyCode is on the response; Intl.NumberFormat is in the browser.

Development

npm install
npm run verify   # typecheck, unit tests, contract check, build, and all four guards
npm run build
npm run test

Four guards, each enforcing a rule that a code review can confirm once but not on every commit:

  • npm run check:contracts — every declared field against Functions/Contracts/StorefrontAccess/Generated/Dc/Catalog*.cs, in both directions, plus the enum members, the sort values and the query parameter names. Needs ILLUMIFY_BACKEND_REPO pointing at a Main clone; it falls back to git show on origin/QA23.20260430.0800, overridable with ILLUMIFY_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 in src or dist.
  • npm run check:no-credentials — nothing that could carry a key, in src or dist.
  • npm run check:no-customer-data — no real tenant slug, id, account or internal hostname in dist or this README. dist/index.d.ts is 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 smoke

npm 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.