Rolls up every change on the v4.7 and v4.8 lines since the last published release, v4.6.0.
Unchained Engine v4.8
Minor
- Breaking (MCP deployments): MCP migrated to the MCP TypeScript SDK v2 — the optional peer dependency is renamed from
@modelcontextprotocol/sdkto@modelcontextprotocol/server, shrinking the installed MCP footprint from 94 packages to 3 (@unchainedshop/api). The/mcpendpoint is now stateless: every request is served by a fresh per-request MCP server built from that request's authenticated context, so noMcp-Session-Idis issued or required,GET/DELETE /mcpreturn405, the abandoned-session memory growth of the old in-process session map is gone, and the endpoint works multi-replica. Cross-user session reuse is now impossible by construction (there are no sessions), while the per-request 401/403 admin wall — including theWWW-Authenticate/.well-known/oauth-protected-resourcemetadata — is unchanged. Clients speaking the modern MCP protocol era (2026-07-28,server/discover) are now supported alongside the legacyinitializeera; tool schemas intools/listare otherwise content-identical but declare JSON Schema draft 2020-12 instead of draft-07. Chat no longer needs any@modelcontextprotocol/*client package: the shop-configuration resources are read in-process (admin-gated), and tools continue to flow through@ai-sdk/mcp, which is protocol-native (chat deployments still install@modelcontextprotocol/server, since the tools are served by the engine's own/mcpendpoint). If the new peer is missing, the engine boots with a warning and/mcpanswers503instead of crashing. The express chat handler was also fixed (it passed an MCP-SDK transport intocreateMCPClient, which throws since@ai-sdk/mcpv2 — fastify had been fixed earlier) and aligned with fastify (stepCountIs(500), no hardcodedtemperature, MCP client closed on all paths). The/mcpendpoint is now covered by an integration test suite (raw JSON-RPC +@ai-sdk/mcpinterop);zodranges in@unchainedshop/apiand@unchainedshop/corenarrowed to^4.2.0. SeeMIGRATION.md. - External dependency reduction: the four library replacements remove 9 installed external packages, including sub-dependencies, from a complete production engine install.
p-memoize+expiry-map(declared by 6 packages; also orphaningtype-fest,mimic-function,map-age-cleaner,p-defer) are replaced bymemoizeWithTTL, newly exported from@unchainedshop/utils— same semantics: concurrent callers share the in-flight promise regardless of TTL, the TTL counts from settlement, resolvednullis cached, rejections are never cached;@kontsedal/loccois replaced by a MongoDB-backed order lock in@unchainedshop/core-ordersusing the samelocco-lockscollection (no migration), same contention semantics (unique key index, upsert-when-expired, 10 × 200 ms retries) — but fail-closed: if the unique index cannot be ensured the module refuses to start instead of handing out locks that do not exclude (previously an index failure was only logged), and a lock timeout now throws a plainErrorinstead of locco'sRetryError;safe-stable-stringifyis replaced bysafeStringify, newly exported from@unchainedshop/logger— circular references serialize as"[Circular]", shared (non-circular) references serialize normally, BigInt (primitive or boxed) becomes a string; JSON log keys now appear in insertion order instead of sorted, which leaves@unchainedshop/loggerwith zero runtime dependencies;hashidsis replaced bycrypto.randomIntover the same unambiguous alphabet — order/quotation/enrollment numbers keep their look but are now always 6 characters (hashids occasionally produced 7); existing persisted numbers are unaffected. - Ticketing:
@parse/node-apnis now an optional peer dependency. PDF-only ticketing setups no longer install its 21-package subtree; Apple Wallet pass-update pushes throw a descriptive error if the package is missing, and a package that is installed but fails to load logs the real load error. Together with the replacements above, a clean install of all framework packages usingnpm --omit=dev --omit=optionalfalls from 93 to 63 external installed package nodes: 30 fewer (−32.3%), covering 28 distinct package names. - Dependency metadata cleanup across all packages. Every package now declares exactly what it imports instead of relying on npm workspace hoisting: 13 core-* modules declare
@unchainedshop/mongodb,@unchainedshop/coredeclareszod, api declaresgraphqlandmongodbas peers plus all 17 core-* packages it imports, plugins declaresbraintreeand@noble/curves/@noble/hashesas optional peers; 11 declared-but-never-imported dependencies were removed. Apps that accidentally imported an undeclared transitive may need to declare it themselves now. Quotation.price: Priceis now exposed in GraphQL (@unchainedshop/api) — buyers can read the proposed unit price (minor units of the quotation's currency) before accepting, instead of having to add the quotation to the cart to learn the price. Proposalmetaandcontext(the seller'squotationContext, potentially containing internal pricing rationale) deliberately stay off the public type. How a proposed price affects cart calculation remains project-specific (transformItemConfiguration+ a project pricing adapter).makeQuotationProposal'squotationContextis now persisted toquotation.contextbefore processing (@unchainedshop/core) — givingmodules.quotations.updateContextits intended caller and letting adapters derive proposals from persisted state inquote(). The manual quotation adapter (shop.unchained.quotations.manual) readsquotation.context.price(proposed unit price, minor units) andquotation.context.expiresaccordingly:makeQuotationProposal(quotationId, { price: 1584 })persists a priced proposal.- Breaking: DocumentDB compatibility mode removed.
UNCHAINED_DOCUMENTDB_COMPAT_MODEis no longer read and theisDocumentDBCompatModeEnabled/assertDocumentDBCompatModeexports have been deleted from@unchainedshop/mongodb. Text indexes and$textqueries now run unconditionally. Supported text-search targets: MongoDB 4.4+, AWS DocumentDB 5.0+ (text search added Feb 2024), AWS DocumentDB 8.0, FerretDB 2.x. Do not upgrade on AWS DocumentDB ≤4.0 or FerretDB 1.x — startup will fail. - MongoDB index refactor across the whole codebase. Compound indexes now cover the common filter+sort shapes on
orders,order_positions,order_payments,order_deliveries,order_discounts,quotations,enrollments,products,product_texts,product_variation_texts,product_media_texts,assortments,assortment_texts,assortment_media_texts,users,token_surrogates,payment_credentials,work_queue, and the payment/delivery/warehousing provider collections. Missing single-field indexes were added onorder_payments.paymentProviderId,order_deliveries.deliveryProviderId,enrollments.periods.orderId,users.services.web3.verified,users.tags,users.lastLogin.timestamp,token_surrogates.walletAddress,currencies.contractAddress,media_objects.path, and the entirecryptopay_transactionscollection (previously unindexed). Redundant singletons covered by new compounds were dropped, and the unusedorder_discounts.triggerindex was removed. SeeMIGRATION.mdfor the ops-side cleanup needed on existing production databases. - Breaking (cryptopay plugin):
CryptopayTransactionsCollectionandconfigureCryptopayModuleare now async — the cryptopay plugin now builds indexes on startup. Callers wiring the plugin directly mustawait cryptopay.configure({ db }). - New
EVENTS_TTL_SECONDSenvironment variable to configure the retention of theeventscollection (previously hardcoded to 2 days). - New TTL (15 min) on
accounts_webauthn_credentials_creation_requests— ephemeral challenge records no longer accumulate. Insertions now set acreated: Datefield; existing pre-upgrade records remain until the next manual cleanup. - Ticketing package no longer registers its own
{meta.cancelled}index ontoken_surrogates— the index is now owned by@unchainedshop/core-warehousing(as part of a composite withproductId). - Breaking:
modules.warehousing.buildAccessKeyForToken(tokenId)renamed tomodules.warehousing.buildAccessKeyFromToken(token). The function now takes the fullTokenSurrogateobject and returnsPromise<string>(no longer nullable). This removes an N+1 lookup pattern in token GraphQL resolvers. - Breaking (Payrexx plugin): environment variables
DATATRANS_SUCCESS_PATH,DATATRANS_ERROR_PATH,DATATRANS_CANCEL_PATHrenamed toPAYREXX_SUCCESS_PATH,PAYREXX_ERROR_PATH,PAYREXX_CANCEL_PATH. - Breaking (Stripe plugin): Stripe API pinned to
2026-03-25.dahlia; peer dependency widened tostripe >= 19 < 22. Upgradestripeto v21 if you depend on this plugin. - Breaking (api context): the internal
_inLoginMethodResponsemarker onuseris now the exported symbolIN_LOGIN_RESPONSEfrom@unchainedshop/api. Custom express/fastify middlewares that read this flag must import the symbol. - Breaking (Fastify adapter): session registration now uses
rolling: falseandsaveUninitialized: false, and login/logout flows explicitly callawait req.session.save(). Custom Fastify session integrations may need the same treatment. - Breaking (Roles):
Mutation.setRolesnow throws when an unknown role name is supplied (previously accepted silently). - New
Query.registeredEventTypes: [String!]!for introspecting events registered via@unchainedshop/events. - New DataLoader
tokenExportStatusLoaderexposed on the GraphQL context; token type resolvers use it to batch status lookups. services.warehousing.isTokenInvalidateable({ token, product? })now accepts an optionalproductto skip the redundant lookup in hot paths.Mutation.changePasswordis now allowed for any logged-in user with the correct current password (was previously admin-only).- Schedule parser rewritten to a field-advancing algorithm — same
schedule.parse.cron(...)/schedule.parse.text(...)API, dramatically faster for daily and hourly cadences. - ESLint upgraded to v10 across the workspace; Stripe SDK updated alongside.
- Permission resolution refactored for performance (Map-based lookup, early exit).
- Cart recalculation no longer blocks boot. The startup provider-invalidation sweep, which recalculated every recently-touched cart inline and could spike MongoDB on a cold connection pool after a restart, is replaced by an
INVALIDATE_CARTSworker (@unchainedshop/plugins) that recalculates carts sequentially through the work queue. It is autoscheduled monthly (1st at 00:00 local server time — so the year-boundary run lands at local midnight on Jan 1, when new-year tax rates take effect; keep the server'sTZset to the relevant jurisdiction), andsetupWorkqueueenqueues an immediate, non-blocking, no-retry work item on boot in the cases that previously recalculated inline (gated byinvalidateProviders/UNCHAINED_DISABLE_PROVIDER_INVALIDATIONas before). - New
GC_GUESTSworker (@unchainedshop/plugins) garbage-collects dormant guest users and their carts. A guest is considered stale once bothcreatedandlastLogin.timestampare older thanguestUserMaxAgeInDays(envUNCHAINED_GUEST_USER_EXPIRY_DAYS, default 30); collected guests are removed viadeleteUserService, which cascade-deletes their open carts. Autoscheduled daily at 02:30. Staleness is decided solely on the user document — neither the user'supdatedfield nor cartupdatedgrants a reprieve, because both are bumped by background/system writes (billing, contact, address normalisation, and theINVALIDATE_CARTSrecalculation sweep) that do not represent the guest actually being present and would otherwise keep dead guests alive indefinitely. Caveat:lastLogin.timestampis the only "presence" signal, and it is bumped only on (re-)login and by theheartbeatmutation. Clients that do not callheartbeatleave it frozen at account creation, so a long-lived guest session with an open cart will be considered stale (and collected together with its cart) oncecreatedages past the cutoff. Callheartbeatperiodically from active sessions if you want them to survive GC.
Patch
- Checkout crash for carts containing quotation items fixed (
@unchainedshop/core-quotations):isProposalValidcalledisExpiredwithout options, which unconditionally destructured{ referenceDate }— everycheckoutCartwith a quotation-based position threwTypeErrorinstead of completing (or instead of the intended "Quotation expired" error).isExpired's options are now optional; checkout of an actually expired quotation now surfacesdetailCode: QuotationInvalidError("Quotation expired or fulfilled…", typo fixed) insideOrderCheckoutError. Regression-covered by a new quotation→cart→checkout integration test. - Logged-in users can now view their own quotations (
@unchainedshop/api):Query.quotationwas admin-only because theloggedInrole never grantedviewQuotation— owners could create quotations (and answer them) but not read them back. Now owner-checked via the existingisOwnedQuotationpredicate. QUOTATION_REMOVEevent is now emitted when requested quotations are deleted during user removal (was registered but never fired).- MCP
quotation_managementtool description no longer advertises the unimplementedREQUESTaction. - Consistent named errors across all core services (
@unchainedshop/core): every service-levelthrownow carries an errorname(surfaced asextensions.detailCodeby API wrappers likeOrderCheckoutError) instead of a bareError— new names:ContactMissingError,BillingAddressMissingError,NoDeliveryProviderError,NoPaymentProviderError,QuotationInvalidError,ProductNotFoundError,InvalidQuantityError,ProductWrongStatusError,OrderNotFoundError,PaymentProviderNotFoundError,DeliveryProviderNotFoundError,SignedURLPreparationError,FileNotFoundError(existingNoItemsErrorunchanged). Messages are unchanged, so message-matching clients keep working. processQuotationServicenow threads the quotation document likeprocessOrderServicethreads orders: re-fetches happen only after an adapter hook actually ran, cutting redundant round-trips per lifecycle transition.modules.quotations.updateProposal/updateContextnow return the post-update document (previously the pre-update one).- Datatrans v2 merchant split rounding fixed (sum now exactly matches order total).
- PayPal Checkout error wrapping now preserves the original
cause. - Cryptopay webhook secret check uses timing-safe comparison.
- Apple Wallet (
apple-handler-express) switched fromres.writeHeadtores.status/res.setHeaderfor Express 5 compatibility. - Apple Wallet PKPass update fix.
- Fastify Apple Wallet wildcard route corrected (
*→/*). - New Fastify route for the PDF print handler.
- Bulk importer error messages now include Zod validation issue summaries.
- Bulk import: sequence is now correctly required.
- Worker module log output redacts work payload (logs structured fields only).
- Explicit Fastify session
save()after login/impersonation. initDbin@unchainedshop/mongodbnow ensures the connection lifecycle.- Linting cleanup of
packages/platform/bin/unchained.js.
Unchained Engine v4.7
Minor
- Server-side bulk export system. A new
BULK_EXPORTworker (@unchainedshop/plugins) moves product, assortment and filter exports off the client and onto the server, generating CSV files through a configurable exporter factory. Exports can opt individual data in or out, are grouped by type, expose a recent-exports view with a count in the admin-ui side navigation, and produce download links that expire after one hour. Meta export/import is supported for products, filters and assortments. - User data export. A default user-export handler exports a user's orders, quotations, reviews and enrollments; the admin-ui gains a user-export flow with configurable fields.
- Batch user lookup.
@unchainedshop/core-userscan now find users in batches by usernames and by emails, backing DataLoader-style resolution instead of per-user queries.
Patch
- Bulk import now batches its payload to avoid large-payload errors, with adjusted product/assortment/filter import normalizers.
- Fixed Stripe attempting to initialize without a configured
apiToken. - Fixed a product-export schema issue and a price-export typo.
- Fixed the bulk-export return value; exported files download automatically on export success.
- Fixed an orders performance regression (v4.6.2).