@hartl-services/medusa-base 2.0.0 → 2.2.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 (34) hide show
  1. package/.medusa/server/src/admin/index.js +3 -1
  2. package/.medusa/server/src/admin/index.mjs +3 -1
  3. package/.medusa/server/src/api/customer-verification-request-throttle.js +50 -0
  4. package/.medusa/server/src/api/middlewares.js +46 -1
  5. package/.medusa/server/src/api/password-policy.js +23 -0
  6. package/.medusa/server/src/api/store/customer-verification/resend/route.js +17 -0
  7. package/.medusa/server/src/email/application/handle-customer-verification-event.js +341 -0
  8. package/.medusa/server/src/email/application/resend-customer-verification.js +65 -0
  9. package/.medusa/server/src/email/application/start-customer-verification-snapshot.js +21 -0
  10. package/.medusa/server/src/email/application/throttle-verification-request.js +49 -0
  11. package/.medusa/server/src/email/customer-verification-snapshot.js +144 -0
  12. package/.medusa/server/src/email/domain/business-keys.js +12 -2
  13. package/.medusa/server/src/email/domain/customer-verification-resend.js +37 -0
  14. package/.medusa/server/src/email/domain/event-schemas.js +18 -2
  15. package/.medusa/server/src/email/plugin-features.js +9 -2
  16. package/.medusa/server/src/email/rendering/default-templates.js +20 -1
  17. package/.medusa/server/src/email/rendering/resolve-shop-configuration.js +5 -1
  18. package/.medusa/server/src/email/types/email-kinds.js +14 -2
  19. package/.medusa/server/src/email/types/template-data.js +9 -1
  20. package/.medusa/server/src/modules/email/migrations/Migration20261001090000.js +16 -0
  21. package/.medusa/server/src/modules/email/service.js +30 -8
  22. package/.medusa/server/src/shop-config/build-food-shop-config.js +16 -1
  23. package/.medusa/server/src/subscribers/customer-verification.js +23 -0
  24. package/.medusa/server/src/workflows/email/dispatch-email-message.js +4 -2
  25. package/README.md +166 -15
  26. package/dist/email/customer-verification-snapshot.d.ts +53 -0
  27. package/dist/email/customer-verification-snapshot.js +143 -0
  28. package/dist/email/types/email-kinds.d.ts +9 -1
  29. package/dist/email/types/email-kinds.js +13 -1
  30. package/dist/email/types/shop-configuration.d.ts +8 -0
  31. package/dist/email/types/template-data.d.ts +9 -0
  32. package/dist/email/types/template-data.js +8 -0
  33. package/dist/shop-config/build-food-shop-config.js +15 -0
  34. package/package.json +2 -2
package/README.md CHANGED
@@ -39,6 +39,8 @@ packing lists) and S3 file storage, and the turnkey `medusa-config.ts` preset
39
39
  - Redirects `GET /` to the admin dashboard and repairs dangling
40
40
  `cart_promotion` links (see "Root redirect" and "Cart-promotion link
41
41
  repair").
42
+ - Requires passwords of at least 15 characters for customers and Admin users
43
+ (see "Password policy").
42
44
  - Exports the shop's `medusa-config.ts` helpers from
43
45
  `@hartl-services/medusa-base/config` and Sentry's `register()` from
44
46
  `@hartl-services/medusa-base/instrumentation` (see "Config helpers" and
@@ -125,6 +127,7 @@ dashboard uses.
125
127
  | `POST` | `/admin/plugin-features/:plugin_key/settings` | Admin user | `AdminPluginFeaturesResponse` |
126
128
  | `POST` | `/admin/plugin-features/:plugin_key/actions/:action_key` | Admin user | `AdminPluginActionRunResponse` (200 sync, 202 background), 409 `AdminPluginActionConflictResponse` while it runs |
127
129
  | `GET` | `/admin/plugin-features/:plugin_key/actions/:action_key/runs` | Admin user | `AdminPluginActionRunsResponse` (`?limit=`, default 1, max 20) |
130
+ | `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" |
128
131
 
129
132
  Every authenticated Admin user may change toggles and settings and run
130
133
  actions (no RBAC by design). Admin plugin DTOs carry the toggles plus
@@ -272,7 +275,9 @@ Code on the cart and checkout hot path (cart middlewares, cart workflow hooks,
272
275
  `orderCreated` handling, cart subscribers) must never read toggles. It stays
273
276
  fail-safe and independent of the database state of this plugin. Toggles
274
277
  control visibility and reachability of features, not the integrity of
275
- existing data.
278
+ existing data. Login is not the cart path: the E-Mail plugin's
279
+ `customer_verification` toggle does switch customer logins, through an
280
+ in-memory copy of the toggle (see "Kontobestätigung per E-Mail").
276
281
 
277
282
  ## Runtime settings
278
283
 
@@ -692,6 +697,30 @@ reverse-proxy rule. The redirect is temporary on purpose: browsers cache a
692
697
  should `/` ever get real content. A shop that needs its own `/` defines
693
698
  `src/api/route.ts` itself.
694
699
 
700
+ ## Password policy
701
+
702
+ Medusa's `emailpass` provider accepts any non-empty password. The base runs a
703
+ middleware on `POST /auth/:actor_type/emailpass/register` (customer sign-up,
704
+ Admin invite) and `POST /auth/:actor_type/emailpass/update` (password reset)
705
+ and answers a password shorter than 15 characters with
706
+ `400 { type: "invalid_data", message: "Das Passwort muss mindestens 15 Zeichen lang sein." }`.
707
+ It applies to every actor type, so customers and Admin users alike. The check
708
+ runs before Medusa's reset-token check, so a rejected password leaves the
709
+ single-use reset link valid for a second attempt.
710
+
711
+ The rule follows NIST SP 800-63B Rev. 4 for a password that is the only
712
+ factor: at least 15 characters, counted as Unicode code points, and no
713
+ composition rules (digits and special characters are allowed, never required).
714
+ The value and the check come from the Contracts package
715
+ (`schemas/password-policy`: `PASSWORD_MIN_LENGTH`, `isPasswordLongEnough`), so
716
+ a storefront validates its forms with the same rule.
717
+
718
+ Login is not checked, so existing shorter passwords keep working until their
719
+ next reset. Accounts created without HTTP (`medusa user`, seed scripts calling
720
+ the Auth module) are not checked either.
721
+
722
+ Nothing to configure.
723
+
695
724
  ## Cart-promotion link repair
696
725
 
697
726
  Medusa's `deletePromotionsWorkflow` only soft-deletes a promotion and leaves
@@ -832,8 +861,11 @@ Options of the `email` namespace:
832
861
  `projectConfig.http.jwtSecret`.
833
862
  - `shop` — a `ShopEmailConfiguration` object: `displayName`,
834
863
  `affiliatePortalUrl`, the `passwordResetUrl`/`inviteUrl` builders and the
835
- `orderNotificationEmail` mailbox. `layout` and `renderers` are optional
836
- overrides, see "Shop configuration and default templates".
864
+ `orderNotificationEmail` mailbox. `verifyAccountUrl` (optional builder,
865
+ like `passwordResetUrl`; the mail appends `#token=`) is the storefront page
866
+ that confirms an account, needed while `customer_verification` is on.
867
+ `layout` and `renderers` are optional overrides, see "Shop configuration and
868
+ default templates".
837
869
 
838
870
  Without `email.shop` the module still boots, but it cannot dispatch: no mail
839
871
  is sent (the dispatch configuration is requested lazily and fails with
@@ -882,7 +914,8 @@ feature is switched on again.
882
914
  | --------------------- | ---------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------- |
883
915
  | (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 |
884
916
  | `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` | jobs, Admin routes, already created messages |
885
- | `account_mails` | Konto-Mails | on | subscribers `password-reset`, `user-invite` | jobs, Admin routes, already created messages |
917
+ | `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 |
918
+ | `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 |
886
919
  | `affiliate_reminders` | Affiliate-Erinnerungen | on | subscriber `affiliate-consignment-reminder-due` | jobs, Admin routes, already created messages |
887
920
 
888
921
  With the plugin switched off the three jobs return immediately, so nothing
@@ -902,6 +935,111 @@ and gates the Terms PDF versions and mail log sections below it on the
902
935
  plugin switch, showing `PluginFeatureDisabledNotice` in their place while it
903
936
  is off.
904
937
 
938
+ ### Kontobestätigung per E-Mail
939
+
940
+ Feature `customer_verification` (child of `account_mails`, default **off**)
941
+ makes customers confirm their e-mail address through a link before their
942
+ first login. It builds on Medusa 2.19's own e-mail verification
943
+ (`projectConfig.http.authVerificationsPerActor`) and adds the mail, the
944
+ toggle and a resend route.
945
+
946
+ How it works while the feature is on:
947
+
948
+ 1. A login (`POST /auth/customer/emailpass`) of an account whose address is
949
+ not confirmed answers `200 { verification_required: true, verification,
950
+ token }`. `token` is an actorless JWT, valid only for the verification
951
+ calls.
952
+ 2. The storefront calls Medusa's `POST /auth/verification/request`
953
+ (`{ code_provider: "token", entity_id: <e-mail>, entity_type: "email" }`)
954
+ with that token. Medusa emits `auth.verification_requested`; the
955
+ `customer-verification` subscriber sends the mail "Bestätige deine
956
+ E-Mail-Adresse" (kind `auth.verification`) to `entity_id`.
957
+ 3. The link is `<STOREFRONT_URL>/<cc>/verify-account#token=<token>`
958
+ (`verifyAccountUrl` plus the fragment, never a query string). The
959
+ storefront page calls `POST /auth/verification/confirm`
960
+ (`{ code: <token>, code_provider: "token" }`); the next login returns a
961
+ normal token.
962
+ 4. The token is valid for 15 minutes (Medusa's default `ttl_seconds` of the
963
+ token provider). Every new request issues a new token and invalidates the
964
+ previous one.
965
+ 5. `POST /store/customer-verification/resend` (`{ email }`, publishable key)
966
+ asks for a new mail without a login. It always answers 202
967
+ `{ accepted: true }`, whether or not an account exists, is confirmed or
968
+ was asked too recently, so addresses cannot be probed. The answer is sent
969
+ before any lookup (equal timing), the work runs detached. A mail follows
970
+ only for an existing, unconfirmed customer account whose last request is
971
+ older than 60 seconds. Admin users and addresses without a customer
972
+ account are ignored. A failure is logged with a fixed message and the
973
+ error class only (never the address), not answered. While the feature is
974
+ off the route answers 404.
975
+ 6. The same 60-second limit per account applies to Medusa's own
976
+ `POST /auth/verification/request`, which the storefront calls at every
977
+ blocked login: a base middleware answers a request within a minute of the
978
+ previous one with `201 { verification }` (Medusa's shape, without the
979
+ stored token hash) and sends nothing, so repeated logins cannot mail-bomb
980
+ an address; the earlier link stays valid. It applies only to the customer
981
+ account's own `emailpass` address while the feature is on, and fails open
982
+ (the request goes on to Medusa) if the check itself fails.
983
+
984
+ Accounts that exist before the feature is switched on need no migration: they
985
+ have no confirmation row, so their next login is answered with
986
+ `verification_required` and the storefront sends them the mail. Social or
987
+ other providers are not affected, only `emailpass` for `customer`.
988
+
989
+ The mail is sent only while the plugin switch, `account_mails` and
990
+ `customer_verification` are on. It is built from the event like the
991
+ password-reset mail: only the token's hash (the business key) is stored, never
992
+ the token or the link, and neither the generic retry job nor an Admin resend
993
+ can touch it (the Event Bus retry and the 60-second resend are the retry paths).
994
+ Medusa takes `entity_id` of the verification request from the request body
995
+ without comparing it, so the subscriber sends only to an address that is the
996
+ identity's own `emailpass` address and only for a customer actor.
997
+
998
+ **Switching at run time.** The preset sets
999
+ `projectConfig.http.authVerificationsPerActor` to an object whose `customer`
1000
+ is a getter: Medusa reads it on every login, and the getter answers from an
1001
+ in-memory copy of the effective toggle. The copy is loaded when the
1002
+ application starts (the E-Mail module's `onApplicationStart`) and, while
1003
+ an unref'd timer refreshes it every 30 seconds, also without traffic (stopped
1004
+ at shutdown); a read that finds the copy older than 30 seconds additionally
1005
+ starts a background refresh (stale-while-revalidate; the getter is synchronous
1006
+ and never throws). A change in the Admin therefore reaches a running instance
1007
+ within about 30 seconds plus the read (each instance has its own copy); the
1008
+ resend route, the request throttle and the subscriber read
1009
+ the toggle itself and follow at once. A failed read keeps the last value and
1010
+ logs a warning; before the first successful read the copy is off. The copy
1011
+ lives on `globalThis` because the preset (`dist`) and the plugin code Medusa
1012
+ loads (`.medusa/server`) are compiled separately. Only the preset
1013
+ (`buildFoodShopConfig` / `defineFoodShopConfig`) sets the getter; a shop with a
1014
+ hand-written config must set `authVerificationsPerActor` itself (a fixed list,
1015
+ changed by a restart) for the toggle to have any effect on logins.
1016
+
1017
+ Login is not on the cart path, so the cart rule above is not affected.
1018
+
1019
+ Things to know when switching it on:
1020
+
1021
+ - Customers who are already logged in stay logged in until their session or
1022
+ JWT expires: Medusa checks the confirmation at login and token generation
1023
+ without an actor, and a token refresh of an existing actor skips it.
1024
+ Only their next login asks for the confirmation.
1025
+ - A password reset does not confirm the address; the customer still
1026
+ confirms through the link at the next login.
1027
+ - The storefront must not store the actorless `token` of a
1028
+ `verification_required` response as the customer session. It is only for
1029
+ the verification request, and it carries no customer.
1030
+
1031
+ Storefront contract (DTOs, schema and path from the Contracts package,
1032
+ `types/customer-verification-store`, `schemas/customer-verification`,
1033
+ `paths/customer-verification`):
1034
+
1035
+ - Read `GET /store/plugin-features/email` and show the confirmation flow only
1036
+ while `features.customer_verification` is `true`.
1037
+ - Handle `verification_required` on login and registration: request the mail
1038
+ with the actorless token, show "check your inbox" and offer "Bestätigungsmail
1039
+ erneut senden" (`customerVerificationStorePaths.post.resend`).
1040
+ - Serve `/<cc>/verify-account`, read `token` from `location.hash`, confirm it
1041
+ and then send the customer to the login.
1042
+
905
1043
  ### Ownership
906
1044
 
907
1045
  - Documents owns document issuance and publishes immutable document facts.
@@ -944,6 +1082,7 @@ installed Medusa 2.19 exports:
944
1082
  | `order.return_requested` | `{ order_id: string; return_id: string }` |
945
1083
  | `order.return_received` | `{ order_id: string; return_id: string }` |
946
1084
  | `auth.password_reset` | `{ entity_id: string; actor_type: string; token: string; metadata?: Record<string, unknown> }` |
1085
+ | `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) |
947
1086
 
948
1087
  The shared immutable Documents artifact is:
949
1088
 
@@ -1037,8 +1176,8 @@ has at most 35 characters. Email runtime-validates every contract before use.
1037
1176
 
1038
1177
  - The accepted kinds are exactly `order.received`, `order.received.admin`, `order.shipped`,
1039
1178
  `order.canceled`, `order.return_requested`, `order.return_received`,
1040
- `order.refunded`, `auth.password_reset`, and
1041
- `affiliate.consignment_report_due`.
1179
+ `order.refunded`, `auth.password_reset`, `auth.verification`,
1180
+ `affiliate.consignment_report_due` and `user.invite`.
1042
1181
  - Every automatic message has a deterministic business key.
1043
1182
  - Every placed Order creates independent customer and Shop intents. The customer
1044
1183
  intent honors `no_notification`; the Shop intent always targets the static
@@ -1060,10 +1199,11 @@ has at most 35 characters. Email runtime-validates every contract before use.
1060
1199
  including any invoice attachment; refund email has no Documents-disabled
1061
1200
  native fallback.
1062
1201
  - Recipients, rendered content, attachment bytes, SMTP responses, credentials,
1063
- reset tokens, and reset URLs are never logged.
1064
- - Reset tokens and reset URLs are never persisted by Email or Notification.
1065
- - Password-reset messages cannot be retried by the generic retry job or resent
1066
- by an Admin.
1202
+ reset and verification tokens, and their URLs are never logged.
1203
+ - Reset and verification tokens and URLs are never persisted by Email or
1204
+ Notification.
1205
+ - Password-reset and account-confirmation messages cannot be retried by the
1206
+ generic retry job or resent by an Admin.
1067
1207
  - Unknown transport outcomes are never automatically resent.
1068
1208
  - Shop branding is the configured `displayName` plus the `logo` setting of
1069
1209
  the E-Mail plugin and the company details of the Shop-Stammdaten; a shop overrides the default layout or
@@ -1172,6 +1312,14 @@ without `iat`) and a note that the mail can be ignored. The lifetime is
1172
1312
  passed as the ephemeral `valid_minutes` next to `reset_url` and is never
1173
1313
  persisted.
1174
1314
 
1315
+ The account confirmation (`auth.verification`) has the subject "Bestätige
1316
+ deine E-Mail-Adresse", a "E-Mail-Adresse bestätigen" button, the link as
1317
+ copyable text, a note that the link is only valid for a limited time (a new
1318
+ one comes with the next login or "Bestätigungsmail erneut senden") and a note
1319
+ that the mail can be ignored. The link comes from `verifyAccountUrl` plus
1320
+ `#token=`; without that builder the message fails with `render_failed`. See
1321
+ "Kontobestätigung per E-Mail".
1322
+
1175
1323
  The affiliate reminder (`affiliate.consignment_report_due`) greets the
1176
1324
  affiliate by `affiliate_display_name`, names the report month ("September
1177
1325
  2026", Austrian month names) and the due date ("10.10.2026") as calendar
@@ -1258,9 +1406,10 @@ process persists success, and a conscious resend can produce a duplicate.
1258
1406
 
1259
1407
  Email transport retries and Event Bus retries are separate. Once an ordinary
1260
1408
  message outcome is persisted, its subscriber completes and the Email retry job
1261
- owns any `retry_pending` work. Password-reset token and URL data remain
1262
- ephemeral; password reset is excluded from the generic retry job and Admin
1263
- resend, and retryable handling stays bounded by the original Event Bus event.
1409
+ owns any `retry_pending` work. Password-reset and verification token and URL
1410
+ data remain ephemeral; both kinds are excluded from the generic retry job and
1411
+ Admin resend, and retryable handling stays bounded by the original Event Bus
1412
+ event.
1264
1413
 
1265
1414
  ### Admin API
1266
1415
 
@@ -2342,7 +2491,7 @@ validate the environment; a missing variable fails with the variable's name.
2342
2491
  | `displayName` | Shop name in the default e-mail templates (a shop's initial seed may use it as store name). Company name, address and UID are no options: documents and tax compliance read them only from the Shop-Stammdaten (Admin, "Hartl Services Plugins"). |
2343
2492
  | `orderNotificationEmail` | Receives order notification e-mails; must look like an e-mail address. |
2344
2493
  | `timeZone` | IANA time zone (validated with `Intl`). Chooses the annual document-number period, the printed dates on documents and the affiliate consignment reminder time zone. |
2345
- | `storefrontCountryCode` | 2-letter lowercase country code of the storefront route. Links: `<STOREFRONT_URL>/<cc>/partnerportal` (affiliate portal), `<STOREFRONT_URL>/<cc>/reset-password` (the e-mail plugin appends `#token=`), referral base `<STOREFRONT_URL>/<cc>`. The Admin invite link is `<MEDUSA_BACKEND_URL>/app/invite?token=`. |
2494
+ | `storefrontCountryCode` | 2-letter lowercase country code of the storefront route. Links: `<STOREFRONT_URL>/<cc>/partnerportal` (affiliate portal), `<STOREFRONT_URL>/<cc>/reset-password` and `<STOREFRONT_URL>/<cc>/verify-account` (the e-mail plugin appends `#token=`), referral base `<STOREFRONT_URL>/<cc>`. The Admin invite link is `<MEDUSA_BACKEND_URL>/app/invite?token=`. |
2346
2495
  | `documentHtmlFactory` | Optional `DocumentHtmlFactory` that replaces the default document markup, typically `createDefaultDocumentHtmlFactory({ timeZone, theme })` from `/config` with the shop's CSS and watermark (see "Layout, logo, payment terms and shop theme"). Must be a function. |
2347
2496
  | `loadEnvFile` | Default `true`: calls `loadEnv(NODE_ENV \|\| "development", process.cwd())`. Pass `false` when the environment is already complete (tests, isolated child processes). |
2348
2497
 
@@ -2382,4 +2531,6 @@ Not configurable on purpose: the plugin order (base first), the Redis event
2382
2531
  bus job policy (3 attempts, exponential backoff from 1 s), S3 cache defaults
2383
2532
  (15 min cleanup, 1 h TTL, absolute expiration), Stripe immediate capture with
2384
2533
  automatic payment methods, the 24 h session lifetime, Admin path `/app`,
2385
- consignment reminders due on day 10, and the Documents module always being part of the base.
2534
+ consignment reminders due on day 10, the Documents module always being part of the base, and
2535
+ `http.authVerificationsPerActor.customer` (a getter that follows the E-Mail `customer_verification` toggle, see
2536
+ "Kontobestätigung per E-Mail").
@@ -0,0 +1,53 @@
1
+ /**
2
+ * In-memory copy of the effective `customer_verification` toggle for the one
3
+ * place that cannot await it: Medusa reads
4
+ * `projectConfig.http.authVerificationsPerActor.customer` synchronously on
5
+ * every login. The shop preset puts `customerVerificationActors()` behind a
6
+ * getter there, so a toggle change in the Admin takes effect without a
7
+ * restart.
8
+ *
9
+ * - Boot: `startCustomerVerificationSnapshot` loads the toggle once.
10
+ * - Afterwards an unref'd timer refreshes every 30 s, so a change reaches an
11
+ * idle instance too. A read that finds the copy older than 30 s (a blocked
12
+ * timer, say) also starts one refresh in the background
13
+ * (stale-while-revalidate) and still answers from the copy. A change thus
14
+ * reaches a running instance within about 30 s plus the duration of the
15
+ * read.
16
+ * - A failed read keeps the last value (initially "off") and is logged; the
17
+ * next attempt waits another 30 s.
18
+ *
19
+ * The state lives on `globalThis`: the preset (`dist`) and the Medusa-loaded
20
+ * plugin code (`.medusa/server`) are compiled separately and would otherwise
21
+ * each hold their own copy. Login is not on the cart path.
22
+ */
23
+ export declare const CUSTOMER_VERIFICATION_REFRESH_MS = 30000;
24
+ export type CustomerVerificationActor = Readonly<{
25
+ auth_provider: string;
26
+ entity_type: string;
27
+ }>;
28
+ /** Reads the toggle once; never rejects. Concurrent calls share one read. */
29
+ export declare const refreshCustomerVerificationSnapshot: () => Promise<void>;
30
+ /** Stops the periodic refresh (application shutdown). The value stays. */
31
+ export declare const stopCustomerVerificationSnapshot: () => void;
32
+ /**
33
+ * Registers the reader, loads the toggle once and keeps it fresh every 30 s
34
+ * (application start). `read` returns the effective toggle; `warn` is the
35
+ * container logger. Starting again replaces the reader and the timer.
36
+ */
37
+ export declare const startCustomerVerificationSnapshot: (input: Readonly<{
38
+ read: () => Promise<boolean>;
39
+ warn: (message: string) => void;
40
+ }>) => Promise<void>;
41
+ /** Synchronous; starts a background refresh when the copy is stale. Never throws. */
42
+ export declare const isCustomerVerificationEnabled: () => boolean;
43
+ /**
44
+ * The value of `authVerificationsPerActor.customer`: Medusa's e-mail
45
+ * verification for the `emailpass` provider while the toggle is on, none
46
+ * while it is off. A fresh array per call.
47
+ */
48
+ export declare const customerVerificationActors: () => CustomerVerificationActor[];
49
+ /**
50
+ * Test hook: sets the value and counts as just refreshed, so it holds for
51
+ * 30 s without a reader; `null` forgets reader and value.
52
+ */
53
+ export declare const setCustomerVerificationEnabledForTests: (enabled: boolean | null) => void;
@@ -0,0 +1,143 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.setCustomerVerificationEnabledForTests = exports.customerVerificationActors = exports.isCustomerVerificationEnabled = exports.startCustomerVerificationSnapshot = exports.stopCustomerVerificationSnapshot = exports.refreshCustomerVerificationSnapshot = exports.CUSTOMER_VERIFICATION_REFRESH_MS = void 0;
4
+ /**
5
+ * In-memory copy of the effective `customer_verification` toggle for the one
6
+ * place that cannot await it: Medusa reads
7
+ * `projectConfig.http.authVerificationsPerActor.customer` synchronously on
8
+ * every login. The shop preset puts `customerVerificationActors()` behind a
9
+ * getter there, so a toggle change in the Admin takes effect without a
10
+ * restart.
11
+ *
12
+ * - Boot: `startCustomerVerificationSnapshot` loads the toggle once.
13
+ * - Afterwards an unref'd timer refreshes every 30 s, so a change reaches an
14
+ * idle instance too. A read that finds the copy older than 30 s (a blocked
15
+ * timer, say) also starts one refresh in the background
16
+ * (stale-while-revalidate) and still answers from the copy. A change thus
17
+ * reaches a running instance within about 30 s plus the duration of the
18
+ * read.
19
+ * - A failed read keeps the last value (initially "off") and is logged; the
20
+ * next attempt waits another 30 s.
21
+ *
22
+ * The state lives on `globalThis`: the preset (`dist`) and the Medusa-loaded
23
+ * plugin code (`.medusa/server`) are compiled separately and would otherwise
24
+ * each hold their own copy. Login is not on the cart path.
25
+ */
26
+ exports.CUSTOMER_VERIFICATION_REFRESH_MS = 30_000;
27
+ const STATE_KEY = Symbol.for("@hartl-services/medusa-base/customer-verification-snapshot");
28
+ const state = () => {
29
+ const holder = globalThis;
30
+ holder[STATE_KEY] ??= {
31
+ enabled: false,
32
+ refreshedAt: null,
33
+ refreshing: null,
34
+ timer: null,
35
+ read: null,
36
+ warn: null,
37
+ };
38
+ return holder[STATE_KEY];
39
+ };
40
+ const logFailure = (current, error) => {
41
+ try {
42
+ current.warn?.(`Customer verification toggle could not be read, keeping "${current.enabled ? "on" : "off"}": ${error instanceof Error ? error.message : "unknown error"}`);
43
+ }
44
+ catch {
45
+ // A broken logger must not turn a failed refresh into a throw.
46
+ }
47
+ };
48
+ /** Reads the toggle once; never rejects. Concurrent calls share one read. */
49
+ const refreshCustomerVerificationSnapshot = () => {
50
+ const current = state();
51
+ if (!current.read) {
52
+ return Promise.resolve();
53
+ }
54
+ if (current.refreshing) {
55
+ return current.refreshing;
56
+ }
57
+ const read = current.read;
58
+ // Started from a microtask so the assignment below always precedes `finally`.
59
+ const refreshing = Promise.resolve().then(async () => {
60
+ try {
61
+ current.enabled = (await read()) === true;
62
+ }
63
+ catch (error) {
64
+ logFailure(current, error);
65
+ }
66
+ finally {
67
+ current.refreshedAt = Date.now();
68
+ current.refreshing = null;
69
+ }
70
+ });
71
+ current.refreshing = refreshing;
72
+ return refreshing;
73
+ };
74
+ exports.refreshCustomerVerificationSnapshot = refreshCustomerVerificationSnapshot;
75
+ const stopTimer = (current) => {
76
+ if (current.timer) {
77
+ clearInterval(current.timer);
78
+ current.timer = null;
79
+ }
80
+ };
81
+ /** Stops the periodic refresh (application shutdown). The value stays. */
82
+ const stopCustomerVerificationSnapshot = () => {
83
+ stopTimer(state());
84
+ };
85
+ exports.stopCustomerVerificationSnapshot = stopCustomerVerificationSnapshot;
86
+ /**
87
+ * Registers the reader, loads the toggle once and keeps it fresh every 30 s
88
+ * (application start). `read` returns the effective toggle; `warn` is the
89
+ * container logger. Starting again replaces the reader and the timer.
90
+ */
91
+ const startCustomerVerificationSnapshot = (input) => {
92
+ const current = state();
93
+ current.read = input.read;
94
+ current.warn = input.warn;
95
+ stopTimer(current);
96
+ current.timer = setInterval(() => {
97
+ void (0, exports.refreshCustomerVerificationSnapshot)();
98
+ }, exports.CUSTOMER_VERIFICATION_REFRESH_MS);
99
+ // An idle timer must not keep the process (or a test run) alive.
100
+ current.timer.unref();
101
+ return (0, exports.refreshCustomerVerificationSnapshot)();
102
+ };
103
+ exports.startCustomerVerificationSnapshot = startCustomerVerificationSnapshot;
104
+ /** Synchronous; starts a background refresh when the copy is stale. Never throws. */
105
+ const isCustomerVerificationEnabled = () => {
106
+ const current = state();
107
+ if (current.read &&
108
+ !current.refreshing &&
109
+ (current.refreshedAt === null ||
110
+ Date.now() - current.refreshedAt >= exports.CUSTOMER_VERIFICATION_REFRESH_MS)) {
111
+ void (0, exports.refreshCustomerVerificationSnapshot)();
112
+ }
113
+ return current.enabled;
114
+ };
115
+ exports.isCustomerVerificationEnabled = isCustomerVerificationEnabled;
116
+ /**
117
+ * The value of `authVerificationsPerActor.customer`: Medusa's e-mail
118
+ * verification for the `emailpass` provider while the toggle is on, none
119
+ * while it is off. A fresh array per call.
120
+ */
121
+ const customerVerificationActors = () => (0, exports.isCustomerVerificationEnabled)()
122
+ ? [{ auth_provider: "emailpass", entity_type: "email" }]
123
+ : [];
124
+ exports.customerVerificationActors = customerVerificationActors;
125
+ /**
126
+ * Test hook: sets the value and counts as just refreshed, so it holds for
127
+ * 30 s without a reader; `null` forgets reader and value.
128
+ */
129
+ const setCustomerVerificationEnabledForTests = (enabled) => {
130
+ const current = state();
131
+ if (enabled === null) {
132
+ stopTimer(current);
133
+ current.enabled = false;
134
+ current.refreshedAt = null;
135
+ current.refreshing = null;
136
+ current.read = null;
137
+ current.warn = null;
138
+ return;
139
+ }
140
+ current.enabled = enabled;
141
+ current.refreshedAt = Date.now();
142
+ };
143
+ exports.setCustomerVerificationEnabledForTests = setCustomerVerificationEnabledForTests;
@@ -1,5 +1,5 @@
1
1
  import { z } from "@medusajs/framework/zod";
2
- export declare const EMAIL_KINDS: readonly ["order.received", "order.received.admin", "order.shipped", "order.canceled", "order.return_requested", "order.return_received", "order.refunded", "auth.password_reset", "affiliate.consignment_report_due", "user.invite"];
2
+ export declare const EMAIL_KINDS: readonly ["order.received", "order.received.admin", "order.shipped", "order.canceled", "order.return_requested", "order.return_received", "order.refunded", "auth.password_reset", "affiliate.consignment_report_due", "user.invite", "auth.verification"];
3
3
  export declare const EmailKindSchema: z.ZodEnum<{
4
4
  "order.received": "order.received";
5
5
  "order.received.admin": "order.received.admin";
@@ -11,5 +11,13 @@ export declare const EmailKindSchema: z.ZodEnum<{
11
11
  "auth.password_reset": "auth.password_reset";
12
12
  "affiliate.consignment_report_due": "affiliate.consignment_report_due";
13
13
  "user.invite": "user.invite";
14
+ "auth.verification": "auth.verification";
14
15
  }>;
15
16
  export type EmailKind = (typeof EMAIL_KINDS)[number];
17
+ /**
18
+ * Kinds whose link carries a one-time token that is never persisted: the
19
+ * subscriber builds the mail from the event, so neither the generic retry
20
+ * job, the automatic dispatch nor an Admin resend may touch them.
21
+ */
22
+ export declare const EPHEMERAL_EMAIL_KINDS: readonly ["auth.password_reset", "auth.verification"];
23
+ export declare const isEphemeralEmailKind: (kind: EmailKind) => boolean;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.EmailKindSchema = exports.EMAIL_KINDS = void 0;
3
+ exports.isEphemeralEmailKind = exports.EPHEMERAL_EMAIL_KINDS = exports.EmailKindSchema = exports.EMAIL_KINDS = void 0;
4
4
  const zod_1 = require("@medusajs/framework/zod");
5
5
  exports.EMAIL_KINDS = [
6
6
  "order.received",
@@ -13,5 +13,17 @@ exports.EMAIL_KINDS = [
13
13
  "auth.password_reset",
14
14
  "affiliate.consignment_report_due",
15
15
  "user.invite",
16
+ "auth.verification",
16
17
  ];
17
18
  exports.EmailKindSchema = zod_1.z.enum(exports.EMAIL_KINDS);
19
+ /**
20
+ * Kinds whose link carries a one-time token that is never persisted: the
21
+ * subscriber builds the mail from the event, so neither the generic retry
22
+ * job, the automatic dispatch nor an Admin resend may touch them.
23
+ */
24
+ exports.EPHEMERAL_EMAIL_KINDS = [
25
+ "auth.password_reset",
26
+ "auth.verification",
27
+ ];
28
+ const isEphemeralEmailKind = (kind) => exports.EPHEMERAL_EMAIL_KINDS.includes(kind);
29
+ exports.isEphemeralEmailKind = isEphemeralEmailKind;
@@ -49,6 +49,14 @@ export type ShopEmailConfiguration = Readonly<{
49
49
  passwordResetUrl: (input: Readonly<{
50
50
  locale: string | null;
51
51
  }>) => string;
52
+ /**
53
+ * Storefront page that confirms an account's e-mail address; the mail
54
+ * appends `#token=<token>`. Only needed while the E-Mail plugin's
55
+ * `customer_verification` feature is used; a mail cannot be built without it.
56
+ */
57
+ verifyAccountUrl?: (input: Readonly<{
58
+ locale: string | null;
59
+ }>) => string;
52
60
  renderers?: Partial<EmailRendererMap>;
53
61
  /**
54
62
  * IANA time zone of dates in the default templates (e.g. the order time in
@@ -363,6 +363,9 @@ export declare const EmailPersistedDataSchemas: {
363
363
  locale: z.ZodNullable<z.ZodString>;
364
364
  }, z.core.$strict>;
365
365
  readonly "user.invite": z.ZodObject<{}, z.core.$strict>;
366
+ readonly "auth.verification": z.ZodObject<{
367
+ locale: z.ZodNullable<z.ZodString>;
368
+ }, z.core.$strict>;
366
369
  };
367
370
  export declare const EmailRenderDataSchemas: {
368
371
  readonly "auth.password_reset": z.ZodObject<{
@@ -374,6 +377,10 @@ export declare const EmailRenderDataSchemas: {
374
377
  invite_url: z.ZodString;
375
378
  expires_at: z.ZodOptional<z.ZodString>;
376
379
  }, z.core.$strict>;
380
+ readonly "auth.verification": z.ZodObject<{
381
+ locale: z.ZodNullable<z.ZodString>;
382
+ verify_url: z.ZodString;
383
+ }, z.core.$strict>;
377
384
  readonly "order.received": z.ZodObject<{
378
385
  order_id: z.ZodString;
379
386
  summary: z.ZodOptional<z.ZodObject<{
@@ -601,6 +608,8 @@ export type EmailEphemeralRenderContextMap = {
601
608
  }> : Kind extends "user.invite" ? Readonly<{
602
609
  invite_url: string;
603
610
  expires_at?: string;
611
+ }> : Kind extends "auth.verification" ? Readonly<{
612
+ verify_url: string;
604
613
  }> : EmptyRenderContext;
605
614
  };
606
615
  export type EmailRenderDataMap = {
@@ -215,6 +215,11 @@ exports.EmailPersistedDataSchemas = {
215
215
  })
216
216
  .strict(),
217
217
  "user.invite": zod_1.z.object({}).strict(),
218
+ "auth.verification": zod_1.z
219
+ .object({
220
+ locale: NullableLocaleSchema,
221
+ })
222
+ .strict(),
218
223
  };
219
224
  exports.EmailRenderDataSchemas = {
220
225
  ...exports.EmailPersistedDataSchemas,
@@ -228,4 +233,7 @@ exports.EmailRenderDataSchemas = {
228
233
  /** When the invite expires (ISO 8601), from the current invite. */
229
234
  expires_at: zod_1.z.string().datetime().optional(),
230
235
  }),
236
+ "auth.verification": exports.EmailPersistedDataSchemas["auth.verification"].extend({
237
+ verify_url: zod_1.z.string().url(),
238
+ }),
231
239
  };
@@ -5,6 +5,7 @@ exports.defineFoodShopConfig = defineFoodShopConfig;
5
5
  const logger_1 = require("@medusajs/framework/logger");
6
6
  const utils_1 = require("@medusajs/framework/utils");
7
7
  const config_1 = require("../public/config");
8
+ const customer_verification_snapshot_1 = require("../email/customer-verification-snapshot");
8
9
  const sentry_logger_1 = require("../public/sentry-logger");
9
10
  const options_1 = require("./options");
10
11
  /**
@@ -156,6 +157,9 @@ function buildFoodShopConfig(options, environment = process.env) {
156
157
  orderNotificationEmail: options.orderNotificationEmail,
157
158
  // The e-mail module appends "#token=<token>".
158
159
  passwordResetUrl: () => (0, config_1.shopUrl)(storefrontOrigin, `${storefrontPath}/reset-password`),
160
+ // Account confirmation (E-Mail toggle `customer_verification`);
161
+ // the e-mail module appends "#token=<token>".
162
+ verifyAccountUrl: () => (0, config_1.shopUrl)(storefrontOrigin, `${storefrontPath}/verify-account`),
159
163
  timeZone: options.timeZone,
160
164
  },
161
165
  },
@@ -227,6 +231,17 @@ function buildFoodShopConfig(options, environment = process.env) {
227
231
  authCors: env.AUTH_CORS,
228
232
  jwtSecret: env.JWT_SECRET,
229
233
  cookieSecret: env.COOKIE_SECRET,
234
+ // Medusa reads this synchronously on every login and keeps the
235
+ // `authVerificationsPerActor` object (defineConfig and ConfigManager
236
+ // copy `http` shallowly), so a getter switches e-mail confirmation of
237
+ // customer accounts at run time. It answers from an in-memory copy of
238
+ // the E-Mail plugin's `customer_verification` toggle, refreshed at
239
+ // most every 30 s (see customer-verification-snapshot.ts).
240
+ authVerificationsPerActor: {
241
+ get customer() {
242
+ return (0, customer_verification_snapshot_1.customerVerificationActors)();
243
+ },
244
+ },
230
245
  },
231
246
  // The storefront turns its customer JWT into a Medusa browser session
232
247
  // at login, so its browser-side cart and payment calls carry the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hartl-services/medusa-base",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "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-in E-Mail, Documents and S3 file storage modules, config helpers with the turnkey food-shop preset, and the Sentry logger wrapper and instrumentation.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -88,7 +88,7 @@
88
88
  "mime-types": "3.0.1",
89
89
  "nodemailer": "6.10.1",
90
90
  "ulid": "^2.4.0",
91
- "@hartl-services/medusa-food-supplements-contracts": "^0.17.0"
91
+ "@hartl-services/medusa-food-supplements-contracts": "^0.20.0"
92
92
  },
93
93
  "devDependencies": {
94
94
  "@medusajs/admin-sdk": "2.19.0",