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

@hartl-services/medusa-base

v2.5.0

Published

Base plugin for Hartl Services Medusa shops: per-plugin feature toggles with a generic Admin settings page, a Store endpoint for storefronts, enforcement helpers, the complete-cart hook composition, the root redirect, cart-promotion link repair, the built

Readme

Medusa Base

Medusa v2.19 base plugin for Hartl Services shops. Every shop installs it. It provides per-plugin feature toggles, runtime settings and actions: a generic Admin hub under Settings → Hartl Services Plugins with one page per plugin, a Store endpoint for storefronts and helpers that plugins use to enforce their toggles and read their settings. Since 1.0.0 it also contains the shop's transactional E-Mail, order Documents (invoices, cancellations, packing lists) and S3 file storage, and the turnkey medusa-config.ts preset (see "E-Mail", "Dokumente", "Dateispeicher (S3)" and "Shop-Config-Preset").

What it does

  • Discovers every registered module whose service declares pluginFeatures (see below). No plugin has to register itself anywhere else.
  • Stores one row per plugin in plugin_feature_state (module plugin_features): the plugin switch, the configured feature values and a version for optimistic concurrency.
  • Serves the effective values to storefronts and the configured plus effective values to the Admin.
  • Exports definePluginFeatures, requirePluginFeature and isPluginFeatureEnabled from @hartl-services/medusa-base for plugins.
  • Emits plugin-features.updated.v1 after every saved Admin toggle update (see "Toggle-change event").
  • Stores runtime settings that plugins declare (secrets encrypted), resolves them with the medusa-config fallback and exports getPluginSettings (see "Runtime settings").
  • Runs actions that plugins declare as workflow ids, records every run with its progress and outcome and rejects a second start while one runs (see "Actions").
  • Holds the shop's master data (Firmenangaben) as its own settings and exports getShopMasterData (see "Shop-Stammdaten").
  • Exports Admin components from @hartl-services/medusa-base/admin-components so a plugin composes the base's sections into its own page and hides widgets of disabled features (see "Admin components for plugin pages").
  • Owns the completeCartWorkflow hooks validate and orderCreated and runs the workflows that plugins declare for them (see "Complete-cart hook composition").
  • Redirects GET / to the admin dashboard and repairs dangling cart_promotion links (see "Root redirect" and "Cart-promotion link repair").
  • Requires passwords of at least 15 characters for customers and Admin users (see "Password policy").
  • Starts Admin draft orders without a customer order confirmation and marks placed 0 € orders without payment as paid (see "Draft orders and 0 € orders").
  • Exports the shop's medusa-config.ts helpers from @hartl-services/medusa-base/config and Sentry's register() from @hartl-services/medusa-base/instrumentation (see "Config helpers" and "Sentry instrumentation").
  • Sends the shop's transactional e-mails over SMTP (module email, see "E-Mail"), issues invoices, cancellation documents and packing lists as private PDFs (module documents, see "Dokumente") and stores files in S3-compatible storage behind the /files gateway (see "Dateispeicher (S3)").
  • Builds a food shop's complete medusa-config.ts from a few shop facts (@hartl-services/medusa-base/shop-config, see "Shop-Config-Preset").

Installation in a shop

pnpm add @hartl-services/medusa-base @hartl-services/medusa-food-supplements-contracts
// medusa-config.ts
plugins: [
  // First in the list: other plugins read their toggles through it.
  {
    resolve: "@hartl-services/medusa-base",
    options: {
      // Optional: key material for secret settings (see "Secrets").
      settingsSecret: process.env.PLUGIN_SETTINGS_SECRET,
      // E-Mail, Documents and the /files gateway: see the sections below
      // (or let the Shop-Config-Preset fill them in).
      // email: { jwtSecret, shop },
      // documents: { numbering, pdf, htmlFactory },
      // files: { gateway },
    },
  },
  // … all other plugins
]

The options are BasePluginOptions. Medusa hands the same options object to every module of the plugin; each module reads only its own namespace:

| Option | Read by | Meaning | |---|---|---| | settingsSecret | plugin_features module | Key material for stored secret settings. Set it from an environment variable of the shop; unset or empty, the base uses projectConfig.http.cookieSecret. | | email | email module | { jwtSecret, shop }: branding, URLs and the JWT secret of the e-mail links (see "E-Mail"). Without email.shop the module boots but sends nothing. | | documents | documents module | { numbering?, pdf?, htmlFactory? } (see "Dokumente"). Defaults: numbering time zone Europe/Vienna, PDF executable google-chrome. | | files | /files routes | { gateway: { signingSecret } }, returned by createS3CacheStorageConfig (see "Dateispeicher (S3)"). |

options: {} is valid: the base then only provides the feature toggles, settings, actions and hooks, the e-mail module cannot dispatch, documents use the defaults and the /files routes answer that signing is not configured. Run medusa db:migrate after installing it (and after upgrading: 0.4.0 adds the tables plugin_setting_state and plugin_action_run; 1.0.0 brings the email and documents tables of the former @hartl-services/medusa-email and @hartl-services/medusa-documents packages with unchanged table and migration names, so a shop that used them needs no data migration).

Upgrading from the separate packages: remove @hartl-services/medusa-email, @hartl-services/medusa-documents, @hartl-services/medusa-s3-cache-storage and @hartl-services/medusa-food-shop-config from package.json and medusa-config.ts, move their options into the namespaces above, change the provider resolve paths to @hartl-services/medusa-base/providers/smtp and @hartl-services/medusa-base/providers/s3-cache (the provider ids smtp and s3-cache are unchanged) and import their /config, /types and /gateway exports from the matching @hartl-services/medusa-base subpath. The documentsEnabled option is gone; Documents is always installed, and the e-mail module follows the Documents invoices toggle.

Peer dependencies are the host's Medusa 2.19 packages (@medusajs/framework, @medusajs/medusa, @medusajs/admin-sdk, @medusajs/js-sdk, @medusajs/ui) plus React, React Router and TanStack Query in the versions the Medusa 2.19 dashboard uses.

HTTP endpoints

| Method | Path | Auth | Response | |---|---|---|---| | GET | / | none | 302 redirect to the configured admin.path (default /app) | | GET | /store/plugin-features | publishable key | StorePluginFeaturesListResponse | | GET | /store/plugin-features/:plugin_key | publishable key | StorePluginFeaturesResponse, 404 for an unknown plugin | | GET | /admin/plugin-features | Admin user | AdminPluginFeaturesListResponse | | POST | /admin/plugin-features | Admin user | AdminPluginFeaturesResponse | | GET | /admin/plugin-features/:plugin_key | Admin user | AdminPluginFeaturesResponse, 404 for an unknown plugin | | GET | /admin/plugin-features/:plugin_key/settings | Admin user | AdminPluginSettingsResponse | | POST | /admin/plugin-features/:plugin_key/settings | Admin user | AdminPluginFeaturesResponse | | POST | /admin/plugin-features/:plugin_key/actions/:action_key | Admin user | AdminPluginActionRunResponse (200 sync, 202 background), 409 AdminPluginActionConflictResponse while it runs | | GET | /admin/plugin-features/:plugin_key/actions/:action_key/runs | Admin user | AdminPluginActionRunsResponse (?limit=, default 1, max 20) | | POST | /store/customer-verification/resend | publishable key | 202 ResendCustomerVerificationResponse ({ accepted: true }) for every address, 404 while customer_verification is off; see "Kontobestätigung per E-Mail" |

Every authenticated Admin user may change toggles and settings and run actions (no RBAC by design). Admin plugin DTOs carry the toggles plus settings (with source, never a secret's value), configured, missing, settings_version, actions, admin_page (the plugin's own page or null) and switchable, so the list is one round trip for the hub. POST takes { plugin_key, expected_version, enabled, features }; features holds configured values for declared keys, missing keys keep their default and an unknown key is 400. An unknown plugin_key is 404, a stale expected_version is 409.

Store values are effective values only; a storefront uses them to hide features. DTOs, schemas and paths come from the Contracts package:

import type { StorePluginFeaturesListResponse } from "@hartl-services/medusa-food-supplements-contracts/types/plugin-features-store"
import { pluginFeaturesStorePaths } from "@hartl-services/medusa-food-supplements-contracts/paths/plugin-features"

const { plugins } = await sdk.client.fetch<StorePluginFeaturesListResponse>(
  pluginFeaturesStorePaths.get.list,
  { method: "GET" }
)

Declaring features in a plugin

A plugin depends on @hartl-services/medusa-base (peer dependency), declares its features once and exposes the declaration as pluginFeatures on its module service:

// src/plugin-features.ts
import { definePluginFeatures } from "@hartl-services/medusa-base"

export const demoPluginFeatures = definePluginFeatures({
  plugin_key: "demo",
  label: "Demo-Plugin",
  // default_enabled: true, // plugin switch default
  features: [
    { key: "coupons", label: "Gutscheine", default_enabled: true },
    {
      key: "coupons_self_service",
      label: "Gutscheine selbst verwalten",
      default_enabled: false,
      parent_key: "coupons",
    },
  ],
})

// src/modules/demo/service.ts
class DemoModuleService extends MedusaService({ /* models */ }) {
  readonly pluginFeatures = demoPluginFeatures
}

Keys match ^[a-z][a-z0-9_]{0,62}$, feature keys are unique, a parent_key must exist and only one nesting level is allowed. definePluginFeatures throws at import time otherwise.

switchable: false declares a plugin that cannot be switched off (the base's own shop master data): the Admin shows no plugin switch, enabled is always true whatever is stored, and POST /admin/plugin-features with enabled: false is 400. It cannot be combined with default_enabled: false.

Gating routes

Put gate entries at the start of the plugin's routes array. An entry without methods is a prefix match and runs before every method-specific middleware:

// src/api/middlewares.ts
import { requirePluginFeature } from "@hartl-services/medusa-base"

export default defineMiddlewares({
  routes: [
    { matcher: "/store/demo", middlewares: [requirePluginFeature(demoPluginFeatures)] },
    {
      matcher: "/store/demo/coupons",
      middlewares: [requirePluginFeature(demoPluginFeatures, "coupons")],
    },
    // … existing entries unchanged
  ],
})

A disabled plugin or feature answers 404. With several keys, all must be enabled. The toggles are read once per request.

Gating subscribers and jobs

Return early:

import { isPluginFeatureEnabled } from "@hartl-services/medusa-base"

if (!(await isPluginFeatureEnabled(container, demoPluginFeatures, "coupons"))) {
  return
}

A read failure throws, so the subscriber or job is retried. A missing base plugin fails with UNEXPECTED_STATE ("@hartl-services/medusa-base ist nicht in medusa-config registriert.").

To check another plugin without importing it, use its plugin key: isPluginFeatureEnabledByKey(container, "documents", "invoices"). It finds the descriptor among the registered modules; an unknown or uninstalled plugin key (and an undeclared feature key) is false.

Toggle-change event

After a successful POST /admin/plugin-features the base emits PLUGIN_FEATURES_UPDATED_EVENT (plugin-features.updated.v1) through the event bus. The payload (PluginFeaturesUpdatedEventSchema) carries the plugin's effective values after the update: { plugin_key, enabled, features: { [key]: effective }, version }. The event is emitted after the write committed; a failed emit is only logged and does not undo the write. Direct service calls (updatePluginFeatures) emit nothing.

A plugin that must react right away (instead of on its next job run) subscribes to it and filters on its own plugin_key:

import {
  PLUGIN_FEATURES_UPDATED_EVENT,
  PluginFeaturesUpdatedEventSchema,
} from "@hartl-services/medusa-food-supplements-contracts/events/plugin-features"

export default async function onToggleChange({ event, container }: SubscriberArgs<unknown>) {
  const payload = PluginFeaturesUpdatedEventSchema.parse(event.data)
  if (payload.plugin_key !== demoPluginFeatures.plugin_key) return
  // re-read the toggles and reconcile
}

export const config: SubscriberConfig = { event: PLUGIN_FEATURES_UPDATED_EVENT }

Events can arrive late or twice; re-read the current toggles instead of trusting the payload for anything that writes.

Never on the cart path

Code on the cart and checkout hot path (cart middlewares, cart workflow hooks, orderCreated handling, cart subscribers) must never read toggles. It stays fail-safe and independent of the database state of this plugin. Toggles control visibility and reachability of features, not the integrity of existing data. Login is not the cart path: the E-Mail plugin's customer_verification toggle does switch customer logins, through an in-memory copy of the toggle (see "Kontobestätigung per E-Mail").

Runtime settings

Configuration a plugin reads at RUN time (for example an external system's URL and API key) is declared in the descriptor and edited on the plugin's Admin page. Boot-time provider configuration (SMTP, S3, Sentry, Redis) and function-valued options stay in medusa-config.ts.

export const demoPluginFeatures = definePluginFeatures({
  plugin_key: "demo",
  label: "Demo-Plugin",
  features: [],
  settings: [
    { key: "api_url", label: "API-URL", type: "url", required: true },
    { key: "api_key", label: "API-Key", type: "secret", required: true },
    { key: "collection", label: "Collection", type: "string", default: "users" },
    { key: "batch_size", label: "Batchgröße", type: "number", min: 1, max: 500, default: 100 },
    {
      key: "mode",
      label: "Modus",
      type: "select",
      options: [
        { value: "live", label: "Live" },
        { value: "test", label: "Test" },
      ],
      default: "test",
    },
  ],
})

class DemoModuleService extends MedusaService({ /* models */ }) {
  readonly pluginFeatures = demoPluginFeatures
  // Values the plugin received from medusa-config; shown as "aus Konfiguration".
  readonly pluginSettingsFallback: PluginSettingsFallback<"api_url" | "api_key">

  constructor(cradle: unknown, options?: DemoOptions) {
    super(...arguments)
    this.pluginSettingsFallback = { api_url: options?.apiUrl, api_key: options?.apiKey }
  }
}
  • Types: string, url (http/https), number (finite, optional inclusive min/max), boolean, secret, select (options: [{ value, label }], non-empty, unique values; the stored value must be one of them), image (the http(s) URL of a PNG/JPEG of at most 2 MiB that the Admin uploads through Medusa's /admin/uploads into the public File Module storage and previews; replacing or removing it leaves the old file in storage). Keys follow the feature key format and are unique. A secret has no default; any other default must match its type, options and bounds. options exist only on select, min/max only on number. required defaults to false. invalidates (optional) lists secret settings of the same plugin that a change of this setting deletes, e.g. invalidates: ["api_key"] on api_url so a stored key is never sent to a new host. definePluginFeatures throws at import time otherwise (also for an invalidates entry that is not a secret of the plugin). The Admin renders a select as a dropdown, gives number inputs their bounds and shows "Beim Ändern muss … neu eingegeben werden" under a setting with invalidates.

  • Precedence per key: stored in the Admin > pluginSettingsFallback (medusa-config) > declared default > not set. A value that does not fit the declared type is skipped. A plugin is configured when every required setting has a value; missing lists the others. A plugin must handle "nicht konfiguriert" at run time (log and skip) instead of failing at boot.

  • Server-side code reads the resolved values with secrets DECRYPTED:

    import { getPluginSettings } from "@hartl-services/medusa-base"
    
    const { values, configured, missing } = await getPluginSettings(
      container, demoPluginFeatures, fallback
    )

    A module service has no access to other modules through its own container; it passes the global container from @medusajs/framework.

  • Updates (POST …/settings with { expected_version, values }) merge values into the stored row in one transaction: null clears a stored value (the setting falls back to medusa-config or its default), a secret that is not sent keeps its stored value, keys the plugin no longer declares are dropped. When a setting with invalidates gets a different stored value (also when it is cleared), the listed secrets are deleted in the same transaction unless the update sets them anew; they appear in changed_keys. Unknown keys and values of the wrong type, outside min/max or not among a select's options answer 400 naming the key, a stale expected_version 409. The response is the full AdminPluginFeaturesResponse.

  • After a saved update the base emits PLUGIN_SETTINGS_UPDATED_EVENT (plugin-settings.updated.v1) with { plugin_key, version, changed_keys } (no values). Direct service calls emit nothing.

Secrets

Secrets are stored AES-256-GCM encrypted ({ enc: "<base64 iv|tag|ciphertext>" } in plugin_setting_state.values) with a key derived by HKDF-SHA256 from the plugin option settingsSecret, else projectConfig.http.cookieSecret. The ciphertext is bound to <plugin_key>:<setting key> (AES-GCM additional authenticated data), so a stored value cannot be copied to another setting or plugin. The Admin never receives a secret's value, only whether it is set. Changing the key material (settingsSecret, or COOKIE_SECRET without it) invalidates stored secrets: they then count as "nicht gesetzt" (a warning is logged once per secret) and must be entered again; a medusa-config fallback applies in the meantime. Secrets saved by an unpublished development build before the key binding existed count as "nicht gesetzt" the same way and must be entered again.

Actions

A plugin declares actions as registered workflow ids; the base runs them from the plugin's Admin page:

actions: [
  {
    key: "sync_now",
    label: "Jetzt synchronisieren",
    workflow_id: "demo-sync-catalog",
    mode: "background", // or "sync"
    confirm: "Den ganzen Katalog jetzt abgleichen?", // optional prompt
  },
],

The workflow receives PluginActionWorkflowInput ({ plugin_key, action_key, triggered_by, run_id }; triggered_by is the Admin user id, run_id the recorded run).

  • Every start is recorded in plugin_action_run (running → completed | failed, started_at, finished_at, triggered_by, message of a failure, progress, result when it is JSON and at most 4 KB).
  • While a run of the same action is running, a start answers 409 ({ type: "conflict", message: "Diese Aktion läuft bereits.", run }) and starts nothing. A partial unique index enforces this also for concurrent requests.
  • sync: the request waits for the workflow and answers { action: { key, status: "completed", result?, run } }. A failure goes through Medusa's error pipeline (a MedusaError keeps its status); the run is recorded as failed.
  • background: the run is started without waiting and the request answers 202 { action: { key, status: "started", run } }. The outcome is recorded on the run when the workflow settles; a failure is also logged.
  • A workflow that is still running when the engine returns (async steps, e.g. a step retry scheduled with retryInterval) keeps its run running (a second start stays 409) until the engine reports its finish; the run is then recorded like a background run. A sync action answers 202 started in that case.
  • Both emit PLUGIN_ACTION_FINISHED_EVENT (plugin-action.finished.v1, { plugin_key, action_key, run_id, status: "completed" | "failed", message? }) when the run ends.
  • GET …/actions/:action_key/runs?limit=1 returns the latest runs, newest first. The Admin polls it every 2 s while the latest run is running.
  • Lost runs: a running run without start or progress for one hour (the process restarted, the workflow was lost) is listed as failed with "Abgebrochen (Neustart)" (listing never writes) and persisted as such the next time the action is started. A run that reports progress regularly is never affected.
  • An unknown action is 404 ("Aktion unbekannt"); a declared action whose workflow is not registered is 500 (UNEXPECTED_STATE naming the workflow, no run is recorded).

Reporting progress

A long-running action reports progress from its workflow; the Admin shows it as a bar ("current / total") with the label:

import {
  reportPluginActionProgress,
  type PluginActionWorkflowInput,
} from "@hartl-services/medusa-base"

const syncPageStep = createStep(
  "demo-sync-page",
  async (input: PluginActionWorkflowInput & { page: number }, { container }) => {
    // … one page of work
    await reportPluginActionProgress(container, input.run_id, {
      current: input.page,
      total: 12, // or null when unknown
      label: `Produkte: Seite ${input.page}`,
    })
  }
)

current and total must be non-negative integers, the label is cut to 200 characters. Without a run_id (the same workflow run from a script) the helper does nothing; invalid progress, an unknown or finished run and a failed write are logged as a warning and dropped, and never fail the action.

Shop-Stammdaten

The base declares its own descriptor on its module service (plugin_key: "shop", label "Shop-Stammdaten", switchable: false, no features). The hub shows it as the first card; its generic page edits the settings like any plugin's:

| Key | Type | Required | |---|---|---| | legal_name (Firmenname) | string | yes | | address_line1, address_line2 | string | line 1 | | postal_code, city | string | yes | | country_code | select: EU-27 plus ch, gb, no, is, li (lowercase ISO-2, German labels) | yes | | vat_number (UID-Nummer) | string | yes | | email, phone | string | no | | website | url | no | | iban, bic | string | no | | company_register_number (Firmenbuchnummer), company_register_court (Firmenbuchgericht) | string | no |

Server-side code reads them exactly as stored in the Admin; there is no medusa-config fallback:

import { getShopMasterData } from "@hartl-services/medusa-base"

const { values, configured, missing } = await getShopMasterData(container)
if (!configured) {
  throw new MedusaError(
    MedusaError.Types.UNEXPECTED_STATE,
    `Shop-Stammdaten unvollständig: ${missing.join(", ")}`
  )
}

The base only checks presence and type; what counts as a usable value (for example a placeholder UID) is the consuming plugin's rule.

Admin pages

  • /settings/hartl-services (sidebar entry "Hartl Services Plugins") is the hub: one card per plugin with status chips, the plugin switch (not for switchable: false) and "Öffnen"; non-switchable plugins (Shop-Stammdaten) come first.
  • The Aktionen section shows per action the progress of a running run ("läuft seit …", bar, label; the button is disabled) and the outcome of the last finished run; a finished run it watched and a second start ("läuft bereits") are announced as toasts.
  • "Öffnen" leads to the plugin's ONE page: its admin_page when declared, otherwise the base's generic page /settings/hartl-services/plugin/<plugin_key> (Funktionen, Einstellungen, Aktionen). The static plugin segment keeps it from colliding with plugin-owned routes.
  • A plugin that needs more than the generic sections (its own forms, lists) declares admin_page: "/settings/hartl-services/<plugin-slug>" and composes that page from the base's components (below). Nested settings routes get no sidebar entry, so the hub is the only way in. definePluginFeatures rejects a path outside /settings/hartl-services/ or under /settings/hartl-services/plugin/.

Admin components for plugin pages

@hartl-services/medusa-base/admin-components (ESM for the Admin's vite build, with types) exports:

| Export | Purpose | | --- | --- | | PluginPageShell({ pluginKey, children }) | Back link to the hub, the plugin's label, description and status chips, then children; loading, "nicht installiert" and error states instead of children | | PluginFeatureSections({ pluginKey }) | Funktionen, Einstellungen, Aktionen exactly as on the generic page | | usePluginFeature(pluginKey) | Query of GET /admin/plugin-features/:plugin_key | | pluginFeatureKeys | Query key factory (all, list(), detail(key)) for invalidation after an own mutation | | usePluginFeatureEnabled(pluginKey, ...featureKeys) | { enabled, isPending, isError } from the shared list query (30 s stale time); plugin switch and every key must be effective; enabled is false while loading and on error | | PluginFeatureGate({ pluginKey, featureKeys?, fallback? }) | Renders children only while enabled, otherwise fallback (default nothing) | | PluginFeatureDisabledNotice({ pluginKey, featureLabel? }) | "Diese Funktion ist unter Einstellungen → Hartl Services Plugins ausgeschaltet." with a link to the hub |

// src/admin/routes/settings/hartl-services/demo/page.tsx
import {
  PluginFeatureSections,
  PluginPageShell,
} from "@hartl-services/medusa-base/admin-components"

import { DemoMappingSection } from "../../../../features/demo/DemoMappingSection"

const DemoPage = () => (
  <PluginPageShell pluginKey="demo">
    <PluginFeatureSections pluginKey="demo" />
    <DemoMappingSection />
  </PluginPageShell>
)

export default DemoPage

Widgets and pages of a disabled feature disappear instead of showing a 404:

// Widget: renders nothing while the feature is off (or still loading).
const DemoOrderWidget = () => (
  <PluginFeatureGate pluginKey="demo" featureKeys={["order_sync"]}>
    <DemoOrderPanel />
  </PluginFeatureGate>
)

// Full page with a sidebar entry that cannot be hidden: explain instead.
const DemoReportsPage = () => {
  const { enabled, isPending } = usePluginFeatureEnabled("demo", "reports")
  if (isPending) return null
  return enabled ? <DemoReports /> : (
    <PluginFeatureDisabledNotice pluginKey="demo" featureLabel="Berichte" />
  )
}

The consuming plugin lists @hartl-services/medusa-base in its dependencies or peerDependencies, so its admin build keeps the import external and the shop's Admin resolves one shared copy. The components use the host Admin's React, React Query, React Router and @medusajs/ui.

Semantics

  • No stored row: every value is its declared default, version is 0.
  • effective(feature) = plugin enabled && configured(feature) && effective(parent). Turning a parent off turns its children off; their configured values stay. Turning the plugin off turns every feature off.
  • Stored values for keys a plugin no longer declares are ignored; a feature added later uses its default until it is saved.
  • Updates are optimistic: expected_version 0 creates the row, any other value updates exactly that version. A concurrent change answers 409; the Admin page reloads and shows a notice.

Complete-cart hook composition

Why. Medusa 2.19.0 allows exactly one handler per workflow hook; a second registration throws "Cannot define multiple hook handlers" at boot. Several plugins need completeCartWorkflow.hooks.validate and the (undeclared, but existing) orderCreated hook, so the base registers both, once (src/workflows/hooks/complete-cart.ts), and runs the plugins' workflows. A plugin must never register these two hooks itself.

How a plugin participates. Its module service implements CompleteCartParticipant (plain data, import type only):

import type { CompleteCartHookDeclarations } from "@hartl-services/medusa-base"

class MyModuleService extends MedusaService({ /* … */ }) {
  completeCartHooks(): CompleteCartHookDeclarations {
    return {
      validate: { workflow_id: "my-validate-complete-cart", order: 150 },
      orderCreated: { workflow_id: "my-order-created", order: 150 },
    }
  }
}
  • A validate workflow receives CompleteCartValidateInput ({ input, cart }) and fails the checkout by throwing (a MedusaError keeps its type, so NOT_ALLOWED/INVALID_DATA answer 400). It must not write.
  • An orderCreated workflow receives CompleteCartOrderCreatedInput ({ order_id, cart_id }) and must be compensable. Create it with store: true and a retentionTime of at least three days (Medusa's own completeCartWorkflow retention): the base cancels it by transaction id also after it finished, and the in-memory workflow engine deletes an unstored finished transaction.
  • The base discovers participants like feature descriptors (every module in medusa-config that is registered in the container). Fail closed: an invalid declaration (non-string or blank workflow_id, a workflow_id that is not a registered workflow, non-finite order, unknown hook name, the same workflow twice for one hook) is UNEXPECTED_STATE and blocks checkout, naming the module key (and the workflow id).

Ordering. Ascending order, then module key, then workflow id. In use: Affiliate 100 (affiliate-validate-complete-cart, finalize-affiliate-order-attribution), tax compliance 200 (freeze-tax-compliance-order-evidence, orderCreated only).

Rollback semantics.

  • validate participants run one after another without their own transaction id (they write nothing). The first error stops the chain and is rethrown unchanged.
  • Participants run and are cancelled with an explicitly built context, like Medusa's runAsStep: the parent's shared transaction context plus its event group (preventReleaseEvents), never the step's parent linkage (parentStepIdempotencyKey), so complete-cart may itself run nested.
  • orderCreated participants run one after another through the workflow engine under the complete-cart transaction id. If participant k fails, the base cancels participants k-1 … 0 in reverse order, logs every failed cancellation (module key, workflow id, cart, order, transaction), and rethrows the original error.
  • When a later complete-cart step fails, the hook's compensation cancels every participant in reverse order, attempts all of them, logs each failure and then throws the first one, so the complete-cart compensation stays failed instead of silently leaving partial state.
  • The base never swallows a participant error and never reads toggles here.

Refunded payment. Before any validate participant, the base refuses a cart whose payment collection already has a refund (NOT_ALLOWED, "Die Zahlung für diesen Warenkorb wurde bereits erstattet. Bitte bestelle erneut."). With Stripe's capture: true a completion failing after the confirmation makes Medusa 2.19.0 refund the payment and keep the cart open with the captured session (the succeeded PaymentIntent cannot be cancelled); completing that cart later would place an order on the refunded payment. A storefront replaces such a cart with a fresh one.

Completion check. POST /store/carts/:id/completion-check (contracts paths/checkout, types/checkout-store) runs the same checks without completing: it loads the cart with Medusa's own complete-cart field list, refuses a completed or refunded cart and runs every validate participant. It answers { cart_id, ready: true } or the error the completion would answer with, and writes nothing. Stripe captures on confirmation, so a Storefront calls it right before confirming a payment. Plugins that guard /store/carts/:id/complete with a middleware register it for this route too (the affiliate plugin's registered-email gate does).

Sentry logger wrapper

@hartl-services/medusa-base/sentry-logger exports withSentryErrorReporting(logger), formerly published as @hartl-services/medusa-sentry-logger. Medusa's HTTP error handler catches thrown route and workflow errors and only logs them, so Sentry's default Node integrations never see them; wrapping the logger reports every logger.error(...) and every Error passed to logger.warn(...) (at level warning). It is a plain library import for medusa-config.ts, not a Medusa plugin feature, and needs @sentry/node (an optional peer dependency):

import { logger } from "@medusajs/framework/logger";
import { withSentryErrorReporting } from "@hartl-services/medusa-base/sentry-logger";

module.exports = defineConfig({
  logger: withSentryErrorReporting(logger),
  // ...
});

Initialize Sentry in instrumentation.ts as usual; without a SENTRY_DSN the wrapper is inert.

Root redirect

Medusa serves nothing at /, so a deployed domain answers it with a 404. The base registers GET / and answers with a 302 to the shop's admin.path (read from the config module, fallback /app), so opening the bare domain lands on the admin dashboard in every shop and in local development without a reverse-proxy rule. The redirect is temporary on purpose: browsers cache a 301 on the bare domain essentially forever, which would be painful to undo should / ever get real content. A shop that needs its own / defines src/api/route.ts itself.

Password policy

Medusa's emailpass provider accepts any non-empty password. The base runs a middleware on POST /auth/:actor_type/emailpass/register (customer sign-up, Admin invite) and POST /auth/:actor_type/emailpass/update (password reset) and answers a password shorter than 15 characters with 400 { type: "invalid_data", message: "Das Passwort muss mindestens 15 Zeichen lang sein." }. It applies to every actor type, so customers and Admin users alike. The check runs before Medusa's reset-token check, so a rejected password leaves the single-use reset link valid for a second attempt.

The rule follows NIST SP 800-63B Rev. 4 for a password that is the only factor: at least 15 characters, counted as Unicode code points, and no composition rules (digits and special characters are allowed, never required). The value and the check come from the Contracts package (schemas/password-policy: PASSWORD_MIN_LENGTH, isPasswordLongEnough), so a storefront validates its forms with the same rule.

Login is not checked, so existing shorter passwords keep working until their next reset. Accounts created without HTTP (medusa user, seed scripts calling the Auth module) are not checked either.

Nothing to configure.

Cart-promotion link repair

Medusa's deletePromotionsWorkflow only soft-deletes a promotion and leaves its cart_promotion link rows behind, so every cart that had it applied reads back a null entry inside cart.promotions. The base removes those links itself:

  • The deletePromotionsWorkflow.hooks.promotionsDeleted hook (workflows/hooks/dismiss-deleted-promotion-cart-links) dismisses the links of the deleted promotions and restores them if a later step fails.
  • The migration script dismiss-orphaned-cart-promotion-links runs once in medusa db:migrate and removes the links that already dangle from earlier deletions. Medusa tracks migration scripts by file name in script_migrations, so the name must not change.

Nothing to configure; medusa db:migrate after installing or upgrading to 0.4.1 is enough.

Draft orders and 0 € orders

Draft orders (Medusa's @medusajs/draft-order plugin, loaded by default) are the shop's way to enter an order in the Admin: sample shipments, orders taken by phone, replacements. Item prices and the shipping amount are set directly in the draft; converting it never involves a payment provider.

  • Customer order confirmation off by default. The createOrderWorkflow.hooks.orderCreated hook (workflows/hooks/draft-order-confirmation-default) sets Medusa's no_notification on every new draft, also when an API caller sent no_notification_order: false. The Admin switches it per draft in the Bestellbestätigung widget on the draft's detail page (GET/POST /admin/draft-orders/:id/order-confirmation, see the E-Mail "Admin API"). On conversion the order-placed subscriber records the customer confirmation as suppressed (notifications_disabled); the copy to the shop's order address is still sent. The shipment mail follows the Admin's "notify customer" choice per shipment, not this switch.
  • 0 € orders count as paid. Medusa derives an order's payment status from its payment collections only, so a converted 0 € draft would stay "Nicht bezahlt". The subscriber order-zero-total-paid runs order-mark-zero-total-paid on order.placed: an order with a total of 0 and no payment collection gets a completed 0 € payment collection and reads "Bezahlt" (payment_status: captured). Orders that already carry a collection, i.e. every checkout order, are left alone. In SQL, a paid order is one whose linked payment_collection (via order_payment_collection) has status = 'completed'.

Orders placed before installing this version keep their state.

Config helpers

@hartl-services/medusa-base/config holds the generic environment helpers a shop's medusa-config.ts needs, so every shop fails at startup with the name of the missing variable instead of a runtime crash. The environment helpers have no dependency on Medusa; the subpath does not load @sentry/node.

| Export | Purpose | |---|---| | ConfigurationPhase | "build" \| "runtime" | | MEDUSA_BUILD_PLACEHOLDERS | Placeholders for ADMIN_CORS, AUTH_CORS, COOKIE_SECRET, DATABASE_URL, JWT_SECRET, MEDUSA_BACKEND_URL, STOREFRONT_URL, STORE_CORS, STRIPE_API_KEY, STRIPE_WEBHOOK_SECRET | | requireEnvironment(environment, names, phase, buildPlaceholders) | Returns every named value (trimmed) or throws one error listing all missing names; in the build phase buildPlaceholders fill in for deployment values | | optionalEnvironment, integerEnvironment, booleanEnvironment | Trimmed optional string, range-checked integer, strict "true"/"false" boolean | | allowsInsecureLocalServices(environment, phase) | true for build, development and test | | resolvePublicOrigin(value, name, allowHttp), shopUrl(origin, path) | Validate a bare HTTP(S) origin; build an absolute URL that cannot leave the origin | | resolveStripe(values) | Checks the sk_ key and the whsec_ webhook secret | | resolveRedisEventBus(environment, phase) | { url } \| undefined: optional in development, required at runtime elsewhere, never used in a build, authenticated rediss:// in production | | createSmtpNotificationModuleConfig, resolveSmtp, SMTP_ENVIRONMENT_VARIABLES, SMTP_BUILD_PLACEHOLDERS, defineShopEmailConfiguration | SMTP notification provider and E-Mail configuration (see "E-Mail") | | createS3CacheStorageConfig, createS3CacheFileModuleConfig, resolveS3Credentials, resolveExpirationStrategy, S3_BUILD_PLACEHOLDERS | S3 file storage (see "Dateispeicher (S3)") | | createDefaultDocumentHtmlFactory, DOCUMENTS_BUILD_PLACEHOLDERS | Documents markup and build placeholder (see "Dokumente") |

medusa build loads the configuration without deployment secrets. Each plugin's own placeholders (SMTP, S3, PDF executable, ...) live with that plugin; the shop merges them:

// medusa-config.ts
import {
  MEDUSA_BUILD_PLACEHOLDERS,
  requireEnvironment,
  resolveRedisEventBus,
  type ConfigurationPhase,
} from "@hartl-services/medusa-base/config"

const phase: ConfigurationPhase =
  process.env.MEDUSA_BUILD_PHASE === "true" ? "build" : "runtime"
const env = requireEnvironment(
  process.env,
  ["DATABASE_URL", "JWT_SECRET", "COOKIE_SECRET"],
  phase,
  { ...MEDUSA_BUILD_PLACEHOLDERS /* , ...other plugins' placeholders */ },
)
const redis = resolveRedisEventBus(process.env, phase)

Sentry instrumentation

@hartl-services/medusa-base/instrumentation exports register(), the hook Medusa's start command calls from the shop's instrumentation.ts. It runs Sentry.init with SENTRY_DSN, SENTRY_ENVIRONMENT (fallback NODE_ENV) and tracesSampleRate: 0; without a SENTRY_DSN Sentry stays inert. The shop's instrumentation.ts is one line:

export { register } from "@hartl-services/medusa-base/instrumentation"

@sentry/node stays an optional peer dependency of the base: a shop that uses this export (or sentry-logger) lists @sentry/node in its own dependencies.

E-Mail

The base owns transactional e-mail for the shop: logical email messages, attempts, rendering, the Medusa Notification integration, SMTP transport, retries and e-mail-specific Admin operations. It is the email module, its SMTP notification provider (providers/smtp), its subscribers, jobs and workflows, and the Admin page /settings/hartl-services/email. There is no separate e-mail package since 1.0.0.

Configuration

The SMTP notification provider is a modules entry built by createSmtpNotificationModuleConfig (from @hartl-services/medusa-base/config); the branding and rendering configuration goes into the email namespace of the base options:

import {
  createSmtpNotificationModuleConfig,
  resolveSmtp,
  SMTP_ENVIRONMENT_VARIABLES,
} from "@hartl-services/medusa-base/config";

// Validates the seven SMTP_* variables (see "SMTP environment helpers").
const smtp = resolveSmtp(
  Object.fromEntries(
    SMTP_ENVIRONMENT_VARIABLES.map((name) => [name, process.env[name] ?? ""]),
  ) as Record<(typeof SMTP_ENVIRONMENT_VARIABLES)[number], string>,
  process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test",
);

export default defineConfig({
  modules: [createSmtpNotificationModuleConfig(smtp)],
  plugins: [
    {
      resolve: "@hartl-services/medusa-base",
      options: {
        email: {
          jwtSecret: process.env.JWT_SECRET!,
          shop: {
            displayName: "Mein Shop",
            affiliatePortalUrl: "https://shop.example.com/de/partner",
            inviteUrl: ({ token }) =>
              `https://shop.example.com/app/invite?token=${token}`,
            orderNotificationEmail: "[email protected]",
            passwordResetUrl: ({ locale }) =>
              `https://shop.example.com/${locale ?? "de"}/passwort-zuruecksetzen`,
          }, // a ShopEmailConfiguration, see `ShopEmailConfiguration` in `@hartl-services/medusa-base/types`
        },
      },
    },
  ],
});

Options of the email namespace:

  • jwtSecret — used for Email's own tokens; typically the same secret as projectConfig.http.jwtSecret.
  • shop — a ShopEmailConfiguration object: displayName, affiliatePortalUrl, the passwordResetUrl/inviteUrl builders and the orderNotificationEmail mailbox. verifyAccountUrl (optional builder, like passwordResetUrl; the mail appends #token=) is the storefront page that confirms an account, needed while customer_verification is on. adminAffiliateApplicationsUrl (optional builder, receives { applicationId }) is the Admin page of the partner applications; the notification about a new application shows its "Im Admin prüfen" button only with it. layout and renderers are optional overrides, see "Shop configuration and default templates".

Without email.shop the module still boots, but it cannot dispatch: no mail is sent (the dispatch configuration is requested lazily and fails with UNEXPECTED_STATE). Shipment and cancellation e-mails wait for a Documents fact only while the Documents invoices feature is on when the message is created (with invoices off, or the Documents plugin switched off, Documents publishes no fact, so the mail is sent without a document). Refund e-mails come only from the Documents refund fact, so there are none while invoices is off. The former documentsEnabled option no longer exists: Documents is always part of the base.

The event contracts the module consumes for shipment, cancellation, refund and affiliate-reminder and partner-application e-mails come from @hartl-services/medusa-food-supplements-contracts/events/documents and /events/affiliate, so installing the base does not pull in Affiliate.

Environment variables (all seven required outside development/test; at least one of SMTP_SECURE/SMTP_REQUIRE_TLS must be true outside development/test):

SMTP_HOST=
SMTP_PORT=
SMTP_SECURE=
SMTP_REQUIRE_TLS=
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=

Set REDIS_URL to use @medusajs/event-bus-redis instead of Medusa's local Event Bus; production requires an authenticated rediss:// URL. See "Event Bus" below for retry/backoff behavior.

Feature toggles

The E-Mail module declares its toggles to @hartl-services/medusa-base under the plugin key email. An Admin switches them under Settings → "Hartl Services Plugins"; Storefronts read the effective values from GET /store/plugin-features/email. A feature is only effective while the plugin switch (default on) is on. A gated subscriber that finds its feature off returns without creating a message; the event is not replayed when the feature is switched on again.

| Key | Label | Default | Gates | Keeps running when off | | --------------------- | ---------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------- | | (plugin switch) | E-Mail | on | every route under /admin/email/* (mail log, resends, terms versions; 404); jobs email-reconcile-attempts, email-reconcile-pending-messages, email-retry-messages; every feature below | checkout and orders; already queued messages stay stored | | order_mails | Bestell-Mails | on | subscribers order-placed, email-shipment-created, email-order-canceled, return-requested, return-received, documents-shipment-invoice-assignment-decided, documents-order-cancellation-ready, documents-refund-ready; route /admin/draft-orders/:id/order-confirmation (404) and its draft-order widget | jobs, Admin routes, already created messages | | account_mails | Konto-Mails | on | subscribers password-reset, user-invite, customer-verification (the last one only with customer_verification on as well) | jobs, Admin routes, already created messages | | customer_verification | Kontobestätigung per E-Mail | off | Medusa's e-mail verification of customer logins (see "Kontobestätigung per E-Mail"), route POST /store/customer-verification/resend (404 while off), subscriber customer-verification; child of account_mails | checkout and orders; confirmed accounts stay confirmed, already created messages | | affiliate_reminders | Affiliate-Mails | on | subscribers affiliate-consignment-reminder-due (reminders to affiliates) and affiliate-application-submitted (notification to the shop about a new partner application) | jobs, Admin routes, already created messages |

With the plugin switched off the three jobs return immediately, so nothing is sent, retried or reconciled until it is switched on again; then the jobs pick up the stored messages (a message whose payload expired in the meantime, such as a password-reset link, is not sent). A toggle read failure fails the subscriber with its fixed sentinel (the Event Bus retries it) or fails the job run.

The plugin declares one setting, edited in the Einstellungen section of its Admin page: logo (type image, optional), the PNG/JPG shown on top of every mail. It is not a Shop-Stammdaten field; Documents has its own logo.

Admin surfaces of a disabled feature are hidden. The E-Mail settings page keeps PluginFeatureSections visible (so the plugin can be switched back on) and gates the Terms PDF versions and mail log sections below it on the plugin switch, showing PluginFeatureDisabledNotice in their place while it is off.

Kontobestätigung per E-Mail

Feature customer_verification (child of account_mails, default off) makes customers confirm their e-mail address through a link before their first login. It builds on Medusa 2.19's own e-mail verification (projectConfig.http.authVerificationsPerActor) and adds the mail, the toggle and a resend route.

How it works while the feature is on:

  1. A login (POST /auth/customer/emailpass) of an account whose address is not confirmed answers 200 { verification_required: true, verification, token }. token is an actorless JWT, valid only for the verification calls.
  2. The storefront calls Medusa's POST /auth/verification/request ({ code_provider: "token", entity_id: <e-mail>, entity_type: "email" }) with that token. Medusa emits auth.verification_requested; the customer-verification subscriber sends the mail "Bestätige deine E-Mail-Adresse" (kind auth.verification) to entity_id.
  3. The link is <STOREFRONT_URL>/<cc>/verify-account#token=<token> (verifyAccountUrl plus the fragment, never a query string). The storefront page calls POST /auth/verification/confirm ({ code: <token>, code_provider: "token" }); the next login returns a normal token.
  4. The token is valid for 15 minutes (Medusa's default ttl_seconds of the token provider). Every new request issues a new token and invalidates the previous one.
  5. POST /store/customer-verification/resend ({ email }, publishable key) asks for a new mail without a login. It always answers 202 { accepted: true }, whether or not an account exists, is confirmed or was asked too recently, so addresses cannot be probed. The answer is sent before any lookup (equal timing), the work runs detached. A mail follows only for an existing, unconfirmed customer account whose last request is older than 60 seconds. Admin users and addresses without a customer account are ignored. A failure is logged with a fixed message and the error class only (never the address), not answered. While the feature is off the route answers 404.
  6. The same 60-second limit per account applies to Medusa's own POST /auth/verification/request, which the storefront calls at every blocked login: a base middleware answers a request within a minute of the previous one with 201 { verification } (Medusa's shape, without the stored token hash) and sends nothing, so repeated logins cannot mail-bomb an address; the earlier link stays valid. It applies only to the customer account's own emailpass address while the feature is on, and fails open (the request goes on to Medusa) if the check itself fails.

Accounts that exist before the feature is switched on need no migration: they have no confirmation row, so their next login is answered with verification_required and the storefront sends them the mail. Social or other providers are not affected, only emailpass for customer.

The mail is sent only while the plugin switch, account_mails and customer_verification are on. It is built from the event like the password-reset mail: only the token's hash (the business key) is stored, never the token or the link, and neither the generic retry job nor an Admin resend can touch it (the Event Bus retry and the 60-second resend are the retry paths). Medusa takes entity_id of the verification request from the request body without comparing it, so the subscriber sends only to an address that is the identity's own emailpass address and only for a customer actor.

Switching at run time. The preset sets projectConfig.http.authVerificationsPerActor to an object whose customer is a getter: Medusa reads it on every login, and the getter answers from an in-memory copy of the effective toggle. The copy is loaded when the application starts (the E-Mail module's onApplicationStart) and, while an unref'd timer refreshes it every 30 seconds, also without traffic (stopped at shutdown); a read that finds the copy older than 30 seconds additionally starts a background refresh (stale-while-revalidate; the getter is synchronous and never throws). A change in the Admin therefore reaches a running instance within about 30 seconds plus the read (each instance has its own copy); the resend route, the request throttle and the subscriber read the toggle itself and follow at once. A failed read keeps the last value and logs a warning; before the first successful read the copy is off. The copy lives on globalThis because the preset (dist) and the plugin code Medusa loads (.medusa/server) are compiled separately. Only the preset (buildFoodShopConfig / defineFoodShopConfig) sets the getter; a shop with a hand-written config must set authVerificationsPerActor itself (a fixed list, changed by a restart) for the toggle to have any effect on logins.

Login is not on the cart path, so the cart rule above is not affected.

Things to know when switching it on:

  • Customers who are already logged in stay logged in until their session or JWT expires: Medusa checks the confirmation at login and token generation without an actor, and a token refresh of an existing actor skips it. Only their next login asks for the confirmation.
  • A password reset does not confirm the address; the customer still confirms through the link at the next login.
  • The storefront must not store the actorless token of a verification_required response as the customer session. It is only for the verification request, and it carries no customer.

Storefront contract (DTOs, schema and path from the Contracts package, types/customer-verification-store, schemas/customer-verification, paths/customer-verification):

  • Read GET /store/plugin-features/email and show the confirmation flow only while features.customer_verification is true.
  • Handle verification_required on login and registration: request the mail with the actorless token, show "check your inbox" and offer "Bestätigungsmail erneut senden" (customerVerificationStorePaths.post.resend).
  • Serve /<cc>/verify-account, read token from location.hash, confirm it and then send the customer to the login.

Ownership

  • Documents owns document issuance and publishes immutable document facts.
  • Affiliate owns reminder eligibility and publishes reminder and application facts.
  • Email resolves recipients, renders content, attaches files, and owns delivery status plus the append-only global Terms PDF history.
  • Public HTTP DTOs, schemas, and paths remain in @hartl-services/medusa-food-supplements-contracts.

The base's /types and /config exports are dedicated server integration surfaces for the backend. The Documents and Affiliate event contracts live in the Contracts package (events/documents, events/affiliate). The base exports no DML models, services, workflows, migrations, or generated output as a consumer contract.

Event Bus

The backend owns Event Bus selection. Development without REDIS_URL leaves Medusa's local Event Bus active. Configuring REDIS_URL registers @medusajs/event-bus-redis 2.19.0 directly; production requires an authenticated rediss:// URL unless the operator explicitly accepts private network transport risk with REDIS_ALLOW_INSECURE_PRIVATE_NETWORK=true.

Redis Event Bus jobs have at most three total attempts with exponential backoff starting at one second. Completed jobs are removed, while failed jobs are kept for at most one hour and 1,000 entries. There is no custom Event Bus wrapper, startup capability probe, or purge job. Events are at-least-once facts and can be replayed, delayed, or delivered out of order.

Event Contracts

All payload objects are strict. Unless noted otherwise, every string shown below is trimmed and non-empty. Native event names and shapes are fixed to the installed Medusa 2.19 exports:

| Event | Payload | | ------------------------ | ---------------------------------------------------------------------------------------------- | | order.placed | { id: string } | | shipment.created | { id: string; no_notification?: boolean } | | order.canceled | { id: string } | | order.return_requested | { order_id: string; return_id: string } | | order.return_received | { order_id: string; return_id: string } | | auth.password_reset | { entity_id: string; actor_type: string; token: string; metadata?: Record<string, unknown> } | | auth.verification_requested | { entity_id: string; entity_type: string; code_provider: string; auth_identity_id: string; code: string; expires_at: Date \| string; metadata?: Record<string, unknown> }; not strict, code is missing for an already confirmed identity (ignored) |

The shared immutable Documents artifact is:

type DocumentsEmailArtifact = {
  artifact_id: string;
  document_id: string;
  document_number: string;
  file_id: string;
  filename: string;
  content_type: "application/pdf";
};

The versioned project events are exact integration contracts:

documents.shipment_invoice_assignment.decided.v1

type Payload =
  | {
      schema_version: 1;
      order_id: string;
      fulfillment_id: string;
      decision: "non_carrier";
    }
  | {
      schema_version: 1;
      order_id: string;
      fulfillment_id: string;
      decision: "carrier";
      invoice_fulfillment_id: string;
      invoice_artifact: DocumentsEmailArtifact;
    };

For carrier, invoice_fulfillment_id must equal fulfillment_id.

documents.order_cancellation_documents.ready.v1

type Payload = {
  schema_version: 1;
  order_id: string;
  expected_refund_ids: string[];
  attachments: Array<{
    payment_id: string;
    refund_id: string;
    artifact: DocumentsEmailArtifact;
  }>;
};

expected_refund_ids must be sorted and unique. Attachments must be sorted by unique refund_id, and every attachment refund must be expected.

documents.refund_document.ready.v1

type Payload = {
  schema_version: 1;
  order_id: string;
  payment_id: string;
  refund_id: string;
  artifact: DocumentsEmailArtifact | null;
};

affiliate.consignment_report_reminder_due.v1

type Payload = {
  schema_version: 1;
  reminder_key: string;
  affiliate_id: string;
  customer_id: string | null;
  affiliate_display_name: string;
  period_start: string;
  due_date: string;
  locale: string | null;
  trigger: "scheduled" | "manual";
};

affiliate.application_submitted.v1

type Payload = {
  schema_version: 1;
  application_id: string;
  customer_id: string;
  customer_email: string | null;
  customer_first_name: string | null;
  customer_last_name: string | null;
  company_name: string | null;
  message: string | null;
};

Affiliate publishes it when POST /store/affiliates/register has stored an application. Email creates one affiliate.application_submitted.admin message per application_id (business key affiliate-application-submitted-admin:<application_id>, so a replay or a concurrent request never sends a second mail) addressed to orderNotificationEmail. The persisted template data are exactly these facts.

period_start is a valid YYYY-MM-01, due_date is a valid YYYY-MM-DD, and reminder_key is exactly affiliate-consignment:<affiliate_id>:<period_start>. locale, when present, has at most 35 characters. Email runtime-validates every contract before use.

Invariants

  • The accepted kinds are exactly order.received, order.received.admin, order.shipped, order.canceled, order.return_requested, order.return_received, order.refunded, auth.password_reset, auth.verification, affiliate.consignment_report_due, affiliate.application_submitted.admin and user.invite.
  • Every automatic message has a deterministic business key.
  • Every placed Order creates independent customer and Shop intents. The customer intent honors no_notification; the Shop intent always targets the static role mailbox selected by the Shop configuration.
  • A new customer order.received intent snapshots the newest global Terms PDF whose effective_at is not later than the authoritative order.created_at. If no version is effective, the customer message is intentionally created without Terms. The Shop order.received.admin intent never receives it.
  • Terms versions and their private files are immutable. Event replays, transport retries, and explicit Admin resends reuse the original message snapshot, so a later or backdated upload never changes an existing message.
  • Event payloads are runtime-validated before use and may be replayed, delayed, or delivered out of order.
  • With the Documents invoices feature on, shipment and cancellation messages remain pending until both their native Medusa fact and immutable Documents fact arrive. The required facts are fixed when the message is created; later facts reuse them.
  • Shipment no_notification suppresses the complete correlated message, including any invoice attachment; refund email has no Documents-disabled native fallback.
  • Recipients, rendered content, attachment bytes, SMTP responses, credentials, reset and verification tokens, and their URLs are never logged.
  • Reset and verification tokens and URLs are never persisted by Email or Notification.
  • Password-reset and account-confirmation messages cannot be retried by the generic retry job or resent by an Admin.
  • Unknown transport outcomes are never automatically resent.
  • Shop branding is the configured displayName plus the logo setting of the E-Mail plugin and the company details of the Shop-Stammdaten; a shop overrides the default layout or single renderers in its own configuration, not in the plugin.

Read this section and the central design before changing Email domain behavior.

Shop configuration and default templates

The E-Mail module ships a neutral German layout in grey tones (table-based, inline styles) and one renderer per accepted kind. The layout shows, from top to bottom: the logo centered (an image setting of the E-Mail plugin, edited on its Admin page; without one, the configured displayName), a white card with the subject as heading and the renderer's body, a contact line ("Fragen? Dann schreib uns an … oder ruf uns an unter …", only with email/phone), and a footer with the company details (legal_name or displayName, address, UID, Firmenbuchnummer and Firmenbuchgericht, website) plus "Diese E-Mail wurde automatisch versendet.". Missing master data fields are left out. Email reads the logo and the Shop-Stammdaten when it sends a mail, so a change applies to the next mail; if a read fails the mail is sent without that part (logo or company details) instead of failing. Besides the layout, displayName appears in the password-reset and invite subjects. A custom layout receives the same data as shop (EmailShopDetails, every field optional) next to bodyHtml, preheader and subject. A shop therefore needs only displayName, affiliatePortalUrl, inviteUrl, passwordResetUrl and orderNotificationEmail.

The order confirmation (order.received) receives an OrderSummary as summary: the display ID, the items with quantity and gross amount, the shipping method names, subtotal, shipping, discount, included tax and total, plus the billing and shipping address. Email freezes it when the order is placed, so a resend shows the order as it was placed. summary is absent for messages stored before the summary existed; a custom renderer must handle that case.

The shop's order notification (order.received.admin) receives the same