@hartl-services/medusa-base 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/.medusa/server/src/admin/index.js +741 -36
  2. package/.medusa/server/src/admin/index.mjs +743 -38
  3. package/.medusa/server/src/api/admin/plugin-features/[plugin_key]/actions/[action_key]/route.js +31 -0
  4. package/.medusa/server/src/api/admin/plugin-features/[plugin_key]/actions/[action_key]/runs/route.js +16 -0
  5. package/.medusa/server/src/api/admin/plugin-features/[plugin_key]/route.js +14 -0
  6. package/.medusa/server/src/api/admin/plugin-features/[plugin_key]/settings/route.js +22 -0
  7. package/.medusa/server/src/api/admin/plugin-features/route.js +6 -4
  8. package/.medusa/server/src/api/middlewares.js +22 -8
  9. package/.medusa/server/src/api/plugin-actions.js +221 -0
  10. package/.medusa/server/src/api/plugin-features-http.js +67 -8
  11. package/.medusa/server/src/modules/plugin-features/domain/plugin-action-runs.js +92 -0
  12. package/.medusa/server/src/modules/plugin-features/domain/plugin-settings.js +213 -0
  13. package/.medusa/server/src/modules/plugin-features/domain/secret-box.js +56 -0
  14. package/.medusa/server/src/modules/plugin-features/migrations/Migration20260928205031.js +16 -0
  15. package/.medusa/server/src/modules/plugin-features/migrations/Migration20260928215428.js +17 -0
  16. package/.medusa/server/src/modules/plugin-features/models/plugin-action-run.js +43 -0
  17. package/.medusa/server/src/modules/plugin-features/models/plugin-setting-state.js +32 -0
  18. package/.medusa/server/src/modules/plugin-features/service.js +419 -17
  19. package/.medusa/server/src/public/action-progress.js +67 -0
  20. package/.medusa/server/src/public/descriptor.js +199 -2
  21. package/.medusa/server/src/public/effective-features.js +9 -5
  22. package/.medusa/server/src/public/enforcement.js +18 -8
  23. package/.medusa/server/src/public/index.js +10 -2
  24. package/.medusa/server/src/public/module-access.js +14 -0
  25. package/.medusa/server/src/public/settings.js +17 -0
  26. package/.medusa/server/src/public/shop-master-data.js +95 -0
  27. package/.medusa/server/src/utils/discover-complete-cart-participants.js +9 -2
  28. package/.medusa/server/src/utils/discover-plugin-features.js +47 -9
  29. package/.medusa/server/src/workflows/hooks/complete-cart-participants.js +28 -16
  30. package/README.md +393 -12
  31. package/dist/admin-components/features/plugin-features/components/PluginActionsSection.d.ts +6 -0
  32. package/dist/admin-components/features/plugin-features/components/PluginActionsSection.js +66 -0
  33. package/dist/admin-components/features/plugin-features/components/PluginDetailPage.d.ts +2 -0
  34. package/dist/admin-components/features/plugin-features/components/PluginDetailPage.js +9 -0
  35. package/dist/admin-components/features/plugin-features/components/PluginFeatureGate.d.ts +22 -0
  36. package/dist/admin-components/features/plugin-features/components/PluginFeatureGate.js +18 -0
  37. package/dist/admin-components/features/plugin-features/components/PluginFeatureSections.d.ts +11 -0
  38. package/dist/admin-components/features/plugin-features/components/PluginFeatureSections.js +19 -0
  39. package/dist/admin-components/features/plugin-features/components/PluginFeaturesCard.d.ts +12 -0
  40. package/dist/admin-components/features/plugin-features/components/PluginFeaturesCard.js +34 -0
  41. package/dist/admin-components/features/plugin-features/components/PluginFeaturesSettingsPage.d.ts +5 -0
  42. package/dist/admin-components/features/plugin-features/components/PluginFeaturesSettingsPage.js +16 -0
  43. package/dist/admin-components/features/plugin-features/components/PluginHubCard.d.ts +7 -0
  44. package/dist/admin-components/features/plugin-features/components/PluginHubCard.js +21 -0
  45. package/dist/admin-components/features/plugin-features/components/PluginPageShell.d.ts +13 -0
  46. package/dist/admin-components/features/plugin-features/components/PluginPageShell.js +20 -0
  47. package/dist/admin-components/features/plugin-features/components/PluginSettingsForm.d.ts +7 -0
  48. package/dist/admin-components/features/plugin-features/components/PluginSettingsForm.js +39 -0
  49. package/dist/admin-components/features/plugin-features/components/PluginStatusChips.d.ts +5 -0
  50. package/dist/admin-components/features/plugin-features/components/PluginStatusChips.js +8 -0
  51. package/dist/admin-components/features/plugin-features/hooks/usePluginFeatureEnabled.d.ts +114 -0
  52. package/dist/admin-components/features/plugin-features/hooks/usePluginFeatureEnabled.js +40 -0
  53. package/dist/admin-components/features/plugin-features/hooks/usePluginFeatures.d.ts +392 -0
  54. package/dist/admin-components/features/plugin-features/hooks/usePluginFeatures.js +128 -0
  55. package/dist/admin-components/features/plugin-features/hooks/usePluginSettingsForm.d.ts +93 -0
  56. package/dist/admin-components/features/plugin-features/hooks/usePluginSettingsForm.js +44 -0
  57. package/dist/admin-components/features/plugin-features/lib/action-runs.d.ts +19 -0
  58. package/dist/admin-components/features/plugin-features/lib/action-runs.js +74 -0
  59. package/dist/admin-components/features/plugin-features/lib/plugin-features-admin-client.d.ts +363 -0
  60. package/dist/admin-components/features/plugin-features/lib/plugin-features-admin-client.js +9 -0
  61. package/dist/admin-components/features/plugin-features/lib/plugin-paths.d.ts +4 -0
  62. package/dist/admin-components/features/plugin-features/lib/plugin-paths.js +4 -0
  63. package/dist/admin-components/features/plugin-features/lib/settings-draft.d.ts +28 -0
  64. package/dist/admin-components/features/plugin-features/lib/settings-draft.js +86 -0
  65. package/dist/admin-components/features/plugin-features/queries/pluginFeatureKeys.d.ts +7 -0
  66. package/dist/admin-components/features/plugin-features/queries/pluginFeatureKeys.js +7 -0
  67. package/dist/admin-components/i18n/texts.d.ts +90 -0
  68. package/dist/admin-components/i18n/texts.js +90 -0
  69. package/dist/admin-components/index.d.ts +14 -0
  70. package/dist/admin-components/index.js +14 -0
  71. package/dist/admin-components/lib/errors.d.ts +6 -0
  72. package/dist/admin-components/lib/errors.js +27 -0
  73. package/dist/admin-components/lib/sdk.d.ts +2 -0
  74. package/dist/admin-components/lib/sdk.js +13 -0
  75. package/dist/public/action-progress.d.ts +25 -0
  76. package/dist/public/action-progress.js +66 -0
  77. package/dist/public/descriptor.d.ts +3 -2
  78. package/dist/public/descriptor.js +198 -1
  79. package/dist/public/effective-features.d.ts +2 -1
  80. package/dist/public/effective-features.js +8 -4
  81. package/dist/public/enforcement.d.ts +7 -0
  82. package/dist/public/enforcement.js +17 -7
  83. package/dist/public/index.d.ts +5 -2
  84. package/dist/public/index.js +9 -1
  85. package/dist/public/module-access.d.ts +3 -0
  86. package/dist/public/module-access.js +13 -0
  87. package/dist/public/settings.d.ts +11 -0
  88. package/dist/public/settings.js +16 -0
  89. package/dist/public/shop-master-data.d.ts +25 -0
  90. package/dist/public/shop-master-data.js +94 -0
  91. package/dist/public/types.d.ts +97 -1
  92. package/dist/utils/discover-plugin-features.d.ts +28 -0
  93. package/dist/utils/discover-plugin-features.js +96 -0
  94. package/package.json +7 -3
package/README.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # Medusa Base
2
2
 
3
3
  Medusa v2.19 base plugin for Hartl Services shops. Every shop installs it. It
4
- provides per-plugin feature toggles: a generic Admin page under
5
- **Settings → Hartl Services Plugins**, a Store endpoint for storefronts and
6
- helpers that plugins use to enforce their toggles.
4
+ provides per-plugin feature toggles, runtime settings and actions: a generic
5
+ Admin hub under **Settings → Hartl Services Plugins** with one page per
6
+ plugin, a Store endpoint for storefronts and helpers that plugins use to
7
+ enforce their toggles and read their settings.
7
8
 
8
9
  ## What it does
9
10
 
@@ -16,6 +17,19 @@ helpers that plugins use to enforce their toggles.
16
17
  values to the Admin.
17
18
  - Exports `definePluginFeatures`, `requirePluginFeature` and
18
19
  `isPluginFeatureEnabled` from `@hartl-services/medusa-base` for plugins.
20
+ - Emits `plugin-features.updated.v1` after every saved Admin toggle update
21
+ (see "Toggle-change event").
22
+ - Stores runtime settings that plugins declare (secrets encrypted), resolves
23
+ them with the medusa-config fallback and exports `getPluginSettings` (see
24
+ "Runtime settings").
25
+ - Runs actions that plugins declare as workflow ids, records every run with
26
+ its progress and outcome and rejects a second start while one runs (see
27
+ "Actions").
28
+ - Holds the shop's master data (Firmenangaben) as its own settings and
29
+ exports `getShopMasterData` (see "Shop-Stammdaten").
30
+ - Exports Admin components from `@hartl-services/medusa-base/admin-components`
31
+ so a plugin composes the base's sections into its own page and hides
32
+ widgets of disabled features (see "Admin components for plugin pages").
19
33
  - Owns the `completeCartWorkflow` hooks `validate` and `orderCreated` and runs
20
34
  the workflows that plugins declare for them (see "Complete-cart hook
21
35
  composition").
@@ -32,13 +46,21 @@ plugins: [
32
46
  // First in the list: other plugins read their toggles through it.
33
47
  {
34
48
  resolve: "@hartl-services/medusa-base",
35
- options: {},
49
+ options: {
50
+ // Optional: key material for secret settings (see "Secrets").
51
+ settingsSecret: process.env.PLUGIN_SETTINGS_SECRET,
52
+ },
36
53
  },
37
54
  // … all other plugins
38
55
  ]
39
56
  ```
40
57
 
41
- The plugin has no options. Run `medusa db:migrate` after installing it.
58
+ The only option is `settingsSecret` (`BasePluginOptions`, optional): the key
59
+ material for stored secret settings. Set it from an environment variable of
60
+ the shop; unset or empty, the base uses `projectConfig.http.cookieSecret`.
61
+ Run `medusa db:migrate` after installing it (and
62
+ after upgrading: 0.4.0 adds the tables `plugin_setting_state` and
63
+ `plugin_action_run`).
42
64
 
43
65
  Peer dependencies are the host's Medusa 2.19 packages (`@medusajs/framework`,
44
66
  `@medusajs/medusa`, `@medusajs/admin-sdk`, `@medusajs/js-sdk`, `@medusajs/ui`)
@@ -53,8 +75,17 @@ dashboard uses.
53
75
  | `GET` | `/store/plugin-features/:plugin_key` | publishable key | `StorePluginFeaturesResponse`, 404 for an unknown plugin |
54
76
  | `GET` | `/admin/plugin-features` | Admin user | `AdminPluginFeaturesListResponse` |
55
77
  | `POST` | `/admin/plugin-features` | Admin user | `AdminPluginFeaturesResponse` |
56
-
57
- Every authenticated Admin user may change the toggles (no RBAC by design).
78
+ | `GET` | `/admin/plugin-features/:plugin_key` | Admin user | `AdminPluginFeaturesResponse`, 404 for an unknown plugin |
79
+ | `GET` | `/admin/plugin-features/:plugin_key/settings` | Admin user | `AdminPluginSettingsResponse` |
80
+ | `POST` | `/admin/plugin-features/:plugin_key/settings` | Admin user | `AdminPluginFeaturesResponse` |
81
+ | `POST` | `/admin/plugin-features/:plugin_key/actions/:action_key` | Admin user | `AdminPluginActionRunResponse` (200 sync, 202 background), 409 `AdminPluginActionConflictResponse` while it runs |
82
+ | `GET` | `/admin/plugin-features/:plugin_key/actions/:action_key/runs` | Admin user | `AdminPluginActionRunsResponse` (`?limit=`, default 1, max 20) |
83
+
84
+ Every authenticated Admin user may change toggles and settings and run
85
+ actions (no RBAC by design). Admin plugin DTOs carry the toggles plus
86
+ `settings` (with `source`, never a secret's value), `configured`, `missing`,
87
+ `settings_version`, `actions`, `admin_page` (the plugin's own page or
88
+ `null`) and `switchable`, so the list is one round trip for the hub.
58
89
  `POST` takes `{ plugin_key, expected_version, enabled, features }`; `features`
59
90
  holds configured values for declared keys, missing keys keep their default and
60
91
  an unknown key is 400. An unknown `plugin_key` is 404, a stale
@@ -108,6 +139,11 @@ Keys match `^[a-z][a-z0-9_]{0,62}$`, feature keys are unique, a `parent_key`
108
139
  must exist and only one nesting level is allowed. `definePluginFeatures`
109
140
  throws at import time otherwise.
110
141
 
142
+ `switchable: false` declares a plugin that cannot be switched off (the
143
+ base's own shop master data): the Admin shows no plugin switch, `enabled` is
144
+ always `true` whatever is stored, and `POST /admin/plugin-features` with
145
+ `enabled: false` is 400. It cannot be combined with `default_enabled: false`.
146
+
111
147
  ### Gating routes
112
148
 
113
149
  Put gate entries at the start of the plugin's `routes` array. An entry without
@@ -148,6 +184,43 @@ A read failure throws, so the subscriber or job is retried. A missing base
148
184
  plugin fails with `UNEXPECTED_STATE` ("@hartl-services/medusa-base ist nicht in
149
185
  medusa-config registriert.").
150
186
 
187
+ To check another plugin without importing it, use its plugin key:
188
+ `isPluginFeatureEnabledByKey(container, "documents", "invoices")`. It finds
189
+ the descriptor among the registered modules; an unknown or uninstalled plugin
190
+ key (and an undeclared feature key) is `false`.
191
+
192
+ ### Toggle-change event
193
+
194
+ After a successful `POST /admin/plugin-features` the base emits
195
+ `PLUGIN_FEATURES_UPDATED_EVENT` (`plugin-features.updated.v1`) through the
196
+ event bus. The payload (`PluginFeaturesUpdatedEventSchema`) carries the
197
+ plugin's effective values after the update:
198
+ `{ plugin_key, enabled, features: { [key]: effective }, version }`. The event
199
+ is emitted after the write committed; a failed emit is only logged and does
200
+ not undo the write. Direct service calls (`updatePluginFeatures`) emit
201
+ nothing.
202
+
203
+ A plugin that must react right away (instead of on its next job run)
204
+ subscribes to it and filters on its own `plugin_key`:
205
+
206
+ ```ts
207
+ import {
208
+ PLUGIN_FEATURES_UPDATED_EVENT,
209
+ PluginFeaturesUpdatedEventSchema,
210
+ } from "@hartl-services/medusa-food-supplements-contracts/events/plugin-features"
211
+
212
+ export default async function onToggleChange({ event, container }: SubscriberArgs<unknown>) {
213
+ const payload = PluginFeaturesUpdatedEventSchema.parse(event.data)
214
+ if (payload.plugin_key !== demoPluginFeatures.plugin_key) return
215
+ // re-read the toggles and reconcile
216
+ }
217
+
218
+ export const config: SubscriberConfig = { event: PLUGIN_FEATURES_UPDATED_EVENT }
219
+ ```
220
+
221
+ Events can arrive late or twice; re-read the current toggles instead of
222
+ trusting the payload for anything that writes.
223
+
151
224
  ### Never on the cart path
152
225
 
153
226
  Code on the cart and checkout hot path (cart middlewares, cart workflow hooks,
@@ -156,6 +229,310 @@ fail-safe and independent of the database state of this plugin. Toggles
156
229
  control visibility and reachability of features, not the integrity of
157
230
  existing data.
158
231
 
232
+ ## Runtime settings
233
+
234
+ Configuration a plugin reads at RUN time (for example an external system's
235
+ URL and API key) is declared in the descriptor and edited on the plugin's
236
+ Admin page. Boot-time provider configuration (SMTP, S3, Sentry, Redis) and
237
+ function-valued options stay in `medusa-config.ts`.
238
+
239
+ ```ts
240
+ export const demoPluginFeatures = definePluginFeatures({
241
+ plugin_key: "demo",
242
+ label: "Demo-Plugin",
243
+ features: [],
244
+ settings: [
245
+ { key: "api_url", label: "API-URL", type: "url", required: true },
246
+ { key: "api_key", label: "API-Key", type: "secret", required: true },
247
+ { key: "collection", label: "Collection", type: "string", default: "users" },
248
+ { key: "batch_size", label: "Batchgröße", type: "number", min: 1, max: 500, default: 100 },
249
+ {
250
+ key: "mode",
251
+ label: "Modus",
252
+ type: "select",
253
+ options: [
254
+ { value: "live", label: "Live" },
255
+ { value: "test", label: "Test" },
256
+ ],
257
+ default: "test",
258
+ },
259
+ ],
260
+ })
261
+
262
+ class DemoModuleService extends MedusaService({ /* models */ }) {
263
+ readonly pluginFeatures = demoPluginFeatures
264
+ // Values the plugin received from medusa-config; shown as "aus Konfiguration".
265
+ readonly pluginSettingsFallback: PluginSettingsFallback<"api_url" | "api_key">
266
+
267
+ constructor(cradle: unknown, options?: DemoOptions) {
268
+ super(...arguments)
269
+ this.pluginSettingsFallback = { api_url: options?.apiUrl, api_key: options?.apiKey }
270
+ }
271
+ }
272
+ ```
273
+
274
+ - Types: `string`, `url` (http/https), `number` (finite, optional inclusive
275
+ `min`/`max`), `boolean`, `secret`, `select` (`options: [{ value, label }]`,
276
+ non-empty, unique values; the stored value must be one of them). Keys
277
+ follow the feature key format and are unique. A `secret` has no default;
278
+ any other default must match its type, options and bounds. `options` exist
279
+ only on `select`, `min`/`max` only on `number`. `required` defaults to
280
+ `false`. `invalidates` (optional) lists secret settings of the same plugin
281
+ that a change of this setting deletes, e.g. `invalidates: ["api_key"]` on
282
+ `api_url` so a stored key is never sent to a new host. `definePluginFeatures`
283
+ throws at import time otherwise (also for an `invalidates` entry that is not
284
+ a secret of the plugin). The Admin renders a `select` as a dropdown, gives
285
+ number inputs their bounds and shows "Beim Ändern muss … neu eingegeben
286
+ werden" under a setting with `invalidates`.
287
+ - Precedence per key: stored in the Admin > `pluginSettingsFallback`
288
+ (medusa-config) > declared default > not set. A value that does not fit the
289
+ declared type is skipped. A plugin is `configured` when every required
290
+ setting has a value; `missing` lists the others. A plugin must handle "nicht
291
+ konfiguriert" at run time (log and skip) instead of failing at boot.
292
+ - Server-side code reads the resolved values with secrets DECRYPTED:
293
+
294
+ ```ts
295
+ import { getPluginSettings } from "@hartl-services/medusa-base"
296
+
297
+ const { values, configured, missing } = await getPluginSettings(
298
+ container, demoPluginFeatures, fallback
299
+ )
300
+ ```
301
+
302
+ A module service has no access to other modules through its own container;
303
+ it passes the global `container` from `@medusajs/framework`.
304
+ - Updates (`POST …/settings` with `{ expected_version, values }`) merge
305
+ `values` into the stored row in one transaction: `null` clears a stored
306
+ value (the setting falls back to medusa-config or its default), a secret
307
+ that is not sent keeps its stored value, keys the plugin no longer declares
308
+ are dropped. When a setting with `invalidates` gets a different stored
309
+ value (also when it is cleared), the listed secrets are deleted in the same
310
+ transaction unless the update sets them anew; they appear in
311
+ `changed_keys`. Unknown keys and values of the wrong type, outside `min`/`max`
312
+ or not among a select's options answer 400 naming the key, a stale `expected_version` 409. The response is the full
313
+ `AdminPluginFeaturesResponse`.
314
+ - After a saved update the base emits `PLUGIN_SETTINGS_UPDATED_EVENT`
315
+ (`plugin-settings.updated.v1`) with `{ plugin_key, version, changed_keys }`
316
+ (no values). Direct service calls emit nothing.
317
+
318
+ ### Secrets
319
+
320
+ Secrets are stored AES-256-GCM encrypted (`{ enc: "<base64 iv|tag|ciphertext>" }`
321
+ in `plugin_setting_state.values`) with a key derived by HKDF-SHA256 from the
322
+ plugin option `settingsSecret`, else `projectConfig.http.cookieSecret`. The
323
+ ciphertext is bound to `<plugin_key>:<setting key>` (AES-GCM additional
324
+ authenticated data), so a stored value cannot be copied to another setting or
325
+ plugin. The Admin never receives a secret's value, only whether it is set.
326
+ **Changing the key material (`settingsSecret`, or `COOKIE_SECRET` without it)
327
+ invalidates stored secrets:** they then count as "nicht gesetzt" (a warning is
328
+ logged once per secret) and must be entered again; a medusa-config fallback
329
+ applies in the meantime. Secrets saved by an unpublished development build
330
+ before the key binding existed count as "nicht gesetzt" the same way and must
331
+ be entered again.
332
+
333
+ ## Actions
334
+
335
+ A plugin declares actions as registered workflow ids; the base runs them from
336
+ the plugin's Admin page:
337
+
338
+ ```ts
339
+ actions: [
340
+ {
341
+ key: "sync_now",
342
+ label: "Jetzt synchronisieren",
343
+ workflow_id: "demo-sync-catalog",
344
+ mode: "background", // or "sync"
345
+ confirm: "Den ganzen Katalog jetzt abgleichen?", // optional prompt
346
+ },
347
+ ],
348
+ ```
349
+
350
+ The workflow receives `PluginActionWorkflowInput`
351
+ (`{ plugin_key, action_key, triggered_by, run_id }`; `triggered_by` is the
352
+ Admin user id, `run_id` the recorded run).
353
+
354
+ - Every start is recorded in `plugin_action_run` (`running` → `completed` |
355
+ `failed`, `started_at`, `finished_at`, `triggered_by`, `message` of a
356
+ failure, `progress`, `result` when it is JSON and at most 4 KB).
357
+ - While a run of the same action is `running`, a start answers 409
358
+ (`{ type: "conflict", message: "Diese Aktion läuft bereits.", run }`) and
359
+ starts nothing. A partial unique index enforces this also for concurrent
360
+ requests.
361
+ - `sync`: the request waits for the workflow and answers
362
+ `{ action: { key, status: "completed", result?, run } }`. A failure goes
363
+ through Medusa's error pipeline (a `MedusaError` keeps its status); the run
364
+ is recorded as failed.
365
+ - `background`: the run is started without waiting and the request answers
366
+ 202 `{ action: { key, status: "started", run } }`. The outcome is recorded
367
+ on the run when the workflow settles; a failure is also logged.
368
+ - A workflow that is still running when the engine returns (async steps,
369
+ e.g. a step retry scheduled with `retryInterval`) keeps its run `running`
370
+ (a second start stays 409) until the engine reports its finish; the run is
371
+ then recorded like a background run. A `sync` action answers 202 `started`
372
+ in that case.
373
+ - Both emit `PLUGIN_ACTION_FINISHED_EVENT` (`plugin-action.finished.v1`,
374
+ `{ plugin_key, action_key, run_id, status: "completed" | "failed", message? }`)
375
+ when the run ends.
376
+ - `GET …/actions/:action_key/runs?limit=1` returns the latest runs, newest
377
+ first. The Admin polls it every 2 s while the latest run is `running`.
378
+ - Lost runs: a `running` run without start or progress for one hour (the
379
+ process restarted, the workflow was lost) is listed as `failed` with
380
+ "Abgebrochen (Neustart)" (listing never writes) and persisted as such the
381
+ next time the action is started. A run that reports progress regularly is
382
+ never affected.
383
+ - An unknown action is 404 ("Aktion unbekannt"); a declared action whose
384
+ workflow is not registered is 500 (`UNEXPECTED_STATE` naming the workflow,
385
+ no run is recorded).
386
+
387
+ ### Reporting progress
388
+
389
+ A long-running action reports progress from its workflow; the Admin shows it
390
+ as a bar ("current / total") with the label:
391
+
392
+ ```ts
393
+ import {
394
+ reportPluginActionProgress,
395
+ type PluginActionWorkflowInput,
396
+ } from "@hartl-services/medusa-base"
397
+
398
+ const syncPageStep = createStep(
399
+ "demo-sync-page",
400
+ async (input: PluginActionWorkflowInput & { page: number }, { container }) => {
401
+ // … one page of work
402
+ await reportPluginActionProgress(container, input.run_id, {
403
+ current: input.page,
404
+ total: 12, // or null when unknown
405
+ label: `Produkte: Seite ${input.page}`,
406
+ })
407
+ }
408
+ )
409
+ ```
410
+
411
+ `current` and `total` must be non-negative integers, the label is cut to 200
412
+ characters. Without a `run_id` (the same workflow run from a script) the
413
+ helper does nothing; invalid progress, an unknown or finished run and a
414
+ failed write are logged as a warning and dropped, and never fail the action.
415
+
416
+ ## Shop-Stammdaten
417
+
418
+ The base declares its own descriptor on its module service
419
+ (`plugin_key: "shop"`, label "Shop-Stammdaten", `switchable: false`, no
420
+ features). The hub shows it as the first card; its generic page edits the
421
+ settings like any plugin's:
422
+
423
+ | Key | Type | Required |
424
+ |---|---|---|
425
+ | `legal_name` (Firmenname) | string | yes |
426
+ | `address_line1`, `address_line2` | string | line 1 |
427
+ | `postal_code`, `city` | string | yes |
428
+ | `country_code` | select: EU-27 plus `ch`, `gb`, `no`, `is`, `li` (lowercase ISO-2, German labels) | yes |
429
+ | `vat_number` (UID-Nummer) | string | yes |
430
+ | `email`, `phone` | string | no |
431
+ | `website` | url | no |
432
+ | `iban`, `bic` | string | no |
433
+ | `company_register_number` (Firmenbuchnummer), `company_register_court` (Firmenbuchgericht) | string | no |
434
+
435
+ Server-side code reads them with a plugin's own medusa-config values as
436
+ fallback (stored in the Admin wins per key):
437
+
438
+ ```ts
439
+ import { getShopMasterData } from "@hartl-services/medusa-base"
440
+
441
+ const { values, configured, missing } = await getShopMasterData(container, {
442
+ legal_name: options.merchant?.legalName,
443
+ })
444
+ if (!configured) {
445
+ throw new MedusaError(
446
+ MedusaError.Types.UNEXPECTED_STATE,
447
+ `Shop-Stammdaten unvollständig: ${missing.join(", ")}`
448
+ )
449
+ }
450
+ ```
451
+
452
+ The base only checks presence and type; what counts as a usable value
453
+ (for example a placeholder UID) is the consuming plugin's rule.
454
+
455
+ ## Admin pages
456
+
457
+ - `/settings/hartl-services` (sidebar entry "Hartl Services Plugins") is the
458
+ hub: one card per plugin with status chips, the plugin switch (not for
459
+ `switchable: false`) and "Öffnen"; non-switchable plugins (Shop-Stammdaten)
460
+ come first.
461
+ - The Aktionen section shows per action the progress of a running run
462
+ ("läuft seit …", bar, label; the button is disabled) and the outcome of the
463
+ last finished run; a finished run it watched and a second start ("läuft
464
+ bereits") are announced as toasts.
465
+ - "Öffnen" leads to the plugin's ONE page: its `admin_page` when declared,
466
+ otherwise the base's generic page
467
+ `/settings/hartl-services/plugin/<plugin_key>` (Funktionen, Einstellungen,
468
+ Aktionen). The static `plugin` segment keeps it from colliding with
469
+ plugin-owned routes.
470
+ - A plugin that needs more than the generic sections (its own forms, lists)
471
+ declares `admin_page: "/settings/hartl-services/<plugin-slug>"` and composes
472
+ that page from the base's components (below). Nested settings routes get no
473
+ sidebar entry, so the hub is the only way in. `definePluginFeatures`
474
+ rejects a path outside `/settings/hartl-services/` or under
475
+ `/settings/hartl-services/plugin/`.
476
+
477
+ ### Admin components for plugin pages
478
+
479
+ `@hartl-services/medusa-base/admin-components` (ESM for the Admin's vite
480
+ build, with types) exports:
481
+
482
+ | Export | Purpose |
483
+ | --- | --- |
484
+ | `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` |
485
+ | `PluginFeatureSections({ pluginKey })` | Funktionen, Einstellungen, Aktionen exactly as on the generic page |
486
+ | `usePluginFeature(pluginKey)` | Query of `GET /admin/plugin-features/:plugin_key` |
487
+ | `pluginFeatureKeys` | Query key factory (`all`, `list()`, `detail(key)`) for invalidation after an own mutation |
488
+ | `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 |
489
+ | `PluginFeatureGate({ pluginKey, featureKeys?, fallback? })` | Renders `children` only while enabled, otherwise `fallback` (default nothing) |
490
+ | `PluginFeatureDisabledNotice({ pluginKey, featureLabel? })` | "Diese Funktion ist unter Einstellungen → Hartl Services Plugins ausgeschaltet." with a link to the hub |
491
+
492
+ ```tsx
493
+ // src/admin/routes/settings/hartl-services/demo/page.tsx
494
+ import {
495
+ PluginFeatureSections,
496
+ PluginPageShell,
497
+ } from "@hartl-services/medusa-base/admin-components"
498
+
499
+ import { DemoMappingSection } from "../../../../features/demo/DemoMappingSection"
500
+
501
+ const DemoPage = () => (
502
+ <PluginPageShell pluginKey="demo">
503
+ <PluginFeatureSections pluginKey="demo" />
504
+ <DemoMappingSection />
505
+ </PluginPageShell>
506
+ )
507
+
508
+ export default DemoPage
509
+ ```
510
+
511
+ Widgets and pages of a disabled feature disappear instead of showing a 404:
512
+
513
+ ```tsx
514
+ // Widget: renders nothing while the feature is off (or still loading).
515
+ const DemoOrderWidget = () => (
516
+ <PluginFeatureGate pluginKey="demo" featureKeys={["order_sync"]}>
517
+ <DemoOrderPanel />
518
+ </PluginFeatureGate>
519
+ )
520
+
521
+ // Full page with a sidebar entry that cannot be hidden: explain instead.
522
+ const DemoReportsPage = () => {
523
+ const { enabled, isPending } = usePluginFeatureEnabled("demo", "reports")
524
+ if (isPending) return null
525
+ return enabled ? <DemoReports /> : (
526
+ <PluginFeatureDisabledNotice pluginKey="demo" featureLabel="Berichte" />
527
+ )
528
+ }
529
+ ```
530
+
531
+ The consuming plugin lists `@hartl-services/medusa-base` in its
532
+ `dependencies` or `peerDependencies`, so its admin build keeps the import
533
+ external and the shop's Admin resolves one shared copy. The components use
534
+ the host Admin's React, React Query, React Router and `@medusajs/ui`.
535
+
159
536
  ## Semantics
160
537
 
161
538
  - No stored row: every value is its declared default, `version` is 0.
@@ -204,9 +581,10 @@ class MyModuleService extends MedusaService({ /* … */ }) {
204
581
  finished transaction.
205
582
  - The base discovers participants like feature descriptors (every module in
206
583
  `medusa-config` that is registered in the container). Fail closed: an
207
- invalid declaration (non-string or blank `workflow_id`, non-finite `order`,
208
- unknown hook name, the same workflow twice for one hook) is
209
- `UNEXPECTED_STATE` and blocks checkout, naming the module key.
584
+ invalid declaration (non-string or blank `workflow_id`, a `workflow_id` that
585
+ is not a registered workflow, non-finite `order`, unknown hook name, the
586
+ same workflow twice for one hook) is `UNEXPECTED_STATE` and blocks
587
+ checkout, naming the module key (and the workflow id).
210
588
 
211
589
  **Ordering.** Ascending `order`, then module key, then workflow id. In use:
212
590
  Affiliate 100 (`affiliate-validate-complete-cart`,
@@ -218,9 +596,12 @@ Affiliate 100 (`affiliate-validate-complete-cart`,
218
596
  - `validate` participants run one after another without their own transaction
219
597
  id (they write nothing). The first error stops the chain and is rethrown
220
598
  unchanged.
599
+ - Participants run and are cancelled with an explicitly built context, like
600
+ Medusa's `runAsStep`: the parent's shared transaction context plus its
601
+ event group (`preventReleaseEvents`), never the step's parent linkage
602
+ (`parentStepIdempotencyKey`), so complete-cart may itself run nested.
221
603
  - `orderCreated` participants run one after another through the workflow
222
- engine under the complete-cart transaction id, with the parent's event group
223
- (`preventReleaseEvents`). If participant *k* fails, the base cancels
604
+ engine under the complete-cart transaction id. If participant *k* fails, the base cancels
224
605
  participants *k-1 … 0* in reverse order, logs every failed cancellation
225
606
  (module key, workflow id, cart, order, transaction), and rethrows the
226
607
  original error.
@@ -0,0 +1,6 @@
1
+ import type { AdminPluginFeatures } from "@hartl-services/medusa-food-supplements-contracts/types/plugin-features-admin";
2
+ type PluginActionsSectionProps = {
3
+ plugin: AdminPluginFeatures;
4
+ };
5
+ export declare const PluginActionsSection: ({ plugin }: PluginActionsSectionProps) => import("react/jsx-runtime").JSX.Element;
6
+ export {};
@@ -0,0 +1,66 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button, Container, Heading, Text, toast, usePrompt } from "@medusajs/ui";
3
+ import { useEffect, useRef } from "react";
4
+ import { texts } from "../../../i18n/texts";
5
+ import { useLatestPluginActionRun, useRunPluginAction, } from "../hooks/usePluginFeatures";
6
+ import { formatRunTime, progressCount, progressPercent, runTransition, summarizeActionResult, } from "../lib/action-runs";
7
+ /** Progress of a running run: label, "current / total", bar and start time. */
8
+ const RunProgress = ({ run }) => {
9
+ const percent = progressPercent(run.progress);
10
+ const count = progressCount(run.progress);
11
+ const label = run.progress?.label;
12
+ return (_jsxs("div", { className: "mt-2 flex flex-col gap-y-1", role: "status", children: [(label || count) && (_jsx(Text, { size: "small", children: [label, count].filter(Boolean).join(" · ") })), percent !== null && (_jsx("div", { className: "bg-ui-border-base w-full overflow-hidden rounded-full", style: { height: 6 }, role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": percent, children: _jsx("div", { className: "bg-ui-fg-interactive h-full rounded-full", style: { width: `${percent}%`, height: "100%" } }) })), _jsx(Text, { size: "small", className: "text-ui-fg-muted", children: texts.actions.runningSince(formatRunTime(run.started_at)) })] }));
13
+ };
14
+ /** Outcome of the last finished run. */
15
+ const RunStatusLine = ({ run }) => {
16
+ if (run.status === "completed") {
17
+ const summary = summarizeActionResult(run.result);
18
+ return (_jsxs("div", { className: "mt-1", children: [_jsx(Text, { size: "small", className: "text-ui-fg-subtle", children: texts.actions.lastCompleted(formatRunTime(run.finished_at ?? run.started_at)) }), summary && (_jsx(Text, { size: "small", className: "text-ui-fg-subtle", children: summary }))] }));
19
+ }
20
+ return (_jsxs("div", { className: "mt-1", children: [_jsx(Text, { size: "small", className: "text-ui-fg-error", children: texts.actions.lastFailed(formatRunTime(run.finished_at ?? run.started_at)) }), run.message && (_jsx(Text, { size: "small", className: "text-ui-fg-error", children: run.message }))] }));
21
+ };
22
+ const PluginActionRow = ({ pluginKey, action, starting, blocked, onRun, }) => {
23
+ const { data: run } = useLatestPluginActionRun(pluginKey, action.key);
24
+ const previous = useRef(undefined);
25
+ const running = run?.status === "running";
26
+ // Toast when a run this page watched while running has finished.
27
+ useEffect(() => {
28
+ const transition = runTransition(previous.current, run);
29
+ previous.current = run;
30
+ if (transition === "completed") {
31
+ toast.success(texts.toasts.actionCompleted(action.label), {
32
+ description: summarizeActionResult(run?.result) ?? undefined,
33
+ });
34
+ }
35
+ else if (transition === "failed") {
36
+ toast.error(texts.toasts.actionFailed(action.label), {
37
+ description: run?.message ?? texts.errors.unknown,
38
+ });
39
+ }
40
+ }, [run, action.label]);
41
+ return (_jsxs("div", { className: "flex items-start justify-between gap-4 px-6 py-4", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx(Text, { size: "small", weight: "plus", children: action.label }), action.description && (_jsx(Text, { size: "small", className: "text-ui-fg-subtle", children: action.description })), run && (running ? _jsx(RunProgress, { run: run }) : _jsx(RunStatusLine, { run: run }))] }), _jsx(Button, { size: "small", variant: "secondary", className: "shrink-0", isLoading: starting || running, disabled: blocked || starting || running, onClick: () => onRun(action), children: action.label })] }));
42
+ };
43
+ export const PluginActionsSection = ({ plugin }) => {
44
+ const mutation = useRunPluginAction(plugin.plugin_key);
45
+ const prompt = usePrompt();
46
+ const startingKey = mutation.isPending ? mutation.variables?.key : undefined;
47
+ const run = async (action) => {
48
+ if (mutation.isPending) {
49
+ return;
50
+ }
51
+ if (action.confirm) {
52
+ const confirmed = await prompt({
53
+ title: texts.actions.confirmTitle(action.label),
54
+ description: action.confirm,
55
+ confirmText: texts.actions.confirm,
56
+ cancelText: texts.actions.cancel,
57
+ variant: "confirmation",
58
+ });
59
+ if (!confirmed) {
60
+ return;
61
+ }
62
+ }
63
+ mutation.mutate(action);
64
+ };
65
+ return (_jsxs(Container, { className: "divide-y p-0", children: [_jsx("div", { className: "px-6 py-4", children: _jsx(Heading, { level: "h2", children: texts.sections.actions }) }), plugin.actions.map((action) => (_jsx(PluginActionRow, { pluginKey: plugin.plugin_key, action: action, starting: startingKey === action.key, blocked: mutation.isPending && startingKey !== action.key, onRun: (entry) => void run(entry) }, action.key)))] }));
66
+ };
@@ -0,0 +1,2 @@
1
+ /** The base's generic page of a plugin without an own `admin_page`. */
2
+ export declare const PluginDetailPage: () => import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useParams } from "react-router-dom";
3
+ import { PluginFeatureSections } from "./PluginFeatureSections";
4
+ import { PluginPageShell } from "./PluginPageShell";
5
+ /** The base's generic page of a plugin without an own `admin_page`. */
6
+ export const PluginDetailPage = () => {
7
+ const { plugin_key: pluginKey = "" } = useParams();
8
+ return (_jsx(PluginPageShell, { pluginKey: pluginKey, children: _jsx(PluginFeatureSections, { pluginKey: pluginKey }) }));
9
+ };
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from "react";
2
+ type PluginFeatureGateProps = {
3
+ pluginKey: string;
4
+ /** All must be on; none = plugin switch only. */
5
+ featureKeys?: readonly string[];
6
+ /** Rendered while loading, on error and when disabled; `null` by default. */
7
+ fallback?: ReactNode;
8
+ children: ReactNode;
9
+ };
10
+ /** Renders `children` only while the plugin and all `featureKeys` are on. */
11
+ export declare const PluginFeatureGate: ({ pluginKey, featureKeys, fallback, children, }: PluginFeatureGateProps) => import("react/jsx-runtime").JSX.Element;
12
+ type PluginFeatureDisabledNoticeProps = {
13
+ pluginKey: string;
14
+ /** Named in the text when given. */
15
+ featureLabel?: string;
16
+ };
17
+ /**
18
+ * For a full page whose sidebar entry cannot be hidden: says the feature is
19
+ * off and links to the hub.
20
+ */
21
+ export declare const PluginFeatureDisabledNotice: ({ pluginKey, featureLabel, }: PluginFeatureDisabledNoticeProps) => import("react/jsx-runtime").JSX.Element;
22
+ export {};
@@ -0,0 +1,18 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Container, Text } from "@medusajs/ui";
3
+ import { Link } from "react-router-dom";
4
+ import { texts } from "../../../i18n/texts";
5
+ import { usePluginFeatureEnabled } from "../hooks/usePluginFeatureEnabled";
6
+ import { PLUGIN_HUB_PATH } from "../lib/plugin-paths";
7
+ /** Renders `children` only while the plugin and all `featureKeys` are on. */
8
+ export const PluginFeatureGate = ({ pluginKey, featureKeys = [], fallback = null, children, }) => {
9
+ const { enabled } = usePluginFeatureEnabled(pluginKey, ...featureKeys);
10
+ return _jsx(_Fragment, { children: enabled ? children : fallback });
11
+ };
12
+ /**
13
+ * For a full page whose sidebar entry cannot be hidden: says the feature is
14
+ * off and links to the hub.
15
+ */
16
+ export const PluginFeatureDisabledNotice = ({ pluginKey, featureLabel, }) => (_jsxs(Container, { className: "flex flex-col gap-2 px-6 py-4", "data-plugin-key": pluginKey, children: [_jsx(Text, { className: "text-ui-fg-subtle", children: featureLabel
17
+ ? texts.gate.disabledNamed(featureLabel)
18
+ : texts.gate.disabled }), _jsx("div", { children: _jsx(Link, { to: PLUGIN_HUB_PATH, className: "text-ui-fg-interactive hover:text-ui-fg-interactive-hover txt-compact-small-plus", children: texts.gate.openHub }) })] }));
@@ -0,0 +1,11 @@
1
+ type PluginFeatureSectionsProps = {
2
+ pluginKey: string;
3
+ };
4
+ /**
5
+ * The base's sections of one plugin: Funktionen (unless the plugin has
6
+ * neither a switch nor features), Einstellungen (when the plugin declares
7
+ * settings) and Aktionen (when it declares actions). Renders
8
+ * nothing until the plugin is loaded; `PluginPageShell` shows that state.
9
+ */
10
+ export declare const PluginFeatureSections: ({ pluginKey }: PluginFeatureSectionsProps) => import("react/jsx-runtime").JSX.Element | null;
11
+ export {};
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { texts } from "../../../i18n/texts";
3
+ import { usePluginFeature } from "../hooks/usePluginFeatures";
4
+ import { PluginActionsSection } from "./PluginActionsSection";
5
+ import { PluginFeaturesCard } from "./PluginFeaturesCard";
6
+ import { PluginSettingsForm } from "./PluginSettingsForm";
7
+ /**
8
+ * The base's sections of one plugin: Funktionen (unless the plugin has
9
+ * neither a switch nor features), Einstellungen (when the plugin declares
10
+ * settings) and Aktionen (when it declares actions). Renders
11
+ * nothing until the plugin is loaded; `PluginPageShell` shows that state.
12
+ */
13
+ export const PluginFeatureSections = ({ pluginKey }) => {
14
+ const plugin = usePluginFeature(pluginKey).data?.plugin;
15
+ if (!plugin) {
16
+ return null;
17
+ }
18
+ return (_jsxs(_Fragment, { children: [(plugin.switchable || plugin.features.length > 0) && (_jsx(PluginFeaturesCard, { plugin: plugin, title: texts.sections.features })), plugin.settings.length > 0 && _jsx(PluginSettingsForm, { plugin: plugin }), plugin.actions.length > 0 && _jsx(PluginActionsSection, { plugin: plugin })] }));
19
+ };
@@ -0,0 +1,12 @@
1
+ import type { AdminPluginFeatures } from "@hartl-services/medusa-food-supplements-contracts/types/plugin-features-admin";
2
+ type PluginFeaturesCardProps = {
3
+ plugin: AdminPluginFeatures;
4
+ /** Heading of the card; the plugin label when omitted. */
5
+ title?: string;
6
+ };
7
+ /**
8
+ * Plugin switch (unless the plugin is not switchable) plus the feature
9
+ * switches of one plugin; saves immediately.
10
+ */
11
+ export declare const PluginFeaturesCard: ({ plugin, title }: PluginFeaturesCardProps) => import("react/jsx-runtime").JSX.Element;
12
+ export {};