@web-ts-toolkit/message-service 0.51.0 → 0.53.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 (6) hide show
  1. package/README.md +833 -11
  2. package/index.d.mts +270 -943
  3. package/index.d.ts +270 -943
  4. package/index.js +174 -43
  5. package/index.mjs +172 -43
  6. package/package.json +3 -3
package/README.md CHANGED
@@ -13,6 +13,10 @@ Peer dependencies:
13
13
  - `mongoose >= 8`
14
14
  - `express >= 5`
15
15
 
16
+ Runtime: Node `>=22`. Use package-root **named imports** (or CommonJS
17
+ `require('@web-ts-toolkit/message-service')`); there is no default export or public
18
+ deep-import subpath.
19
+
16
20
  Bundling note: `handlebars` and the workspace deps are bundled (`tsup` externals are only `mongoose`/`express`), so only `mongoose` + `express` are peers.
17
21
 
18
22
  ## Highlights
@@ -147,6 +151,131 @@ const { router, service } = createMessageRoutes({
147
151
  });
148
152
  ```
149
153
 
154
+ ### Independent applications in one process
155
+
156
+ Use **separate connections/databases or disjoint collections**, an explicit registry
157
+ per app, and matching schema/service model names. Model names alone do not isolate
158
+ collections: Mongoose normally derives collection names from model names. This is
159
+ separate-app configuration, not shared-collection tenant enforcement. Explicit
160
+ registries never fall back to `defaultRegistry`; configured connections never fall
161
+ back to global models. Direct `archive()` always requires the app's registry.
162
+
163
+ This standalone factory uses only root named imports. Call once per connection;
164
+ register trusted templates in each registry before serving requests. Register the
165
+ configured `${prefix}User` model first if using default email lookup; a custom
166
+ directory resolver needs no User model. Email is disabled when no provider is passed.
167
+
168
+ ```typescript
169
+ import type mongoose from 'mongoose';
170
+ import {
171
+ MessageService,
172
+ TemplateRegistry,
173
+ buildMessageSchema,
174
+ buildMessageArchiveSchema,
175
+ buildMessageRequestSchema,
176
+ type EmailProvider,
177
+ type EmailRecipientResolver,
178
+ type PaymentProvider,
179
+ } from '@web-ts-toolkit/message-service';
180
+
181
+ export async function createIndependentMessaging(
182
+ connection: mongoose.Connection,
183
+ options: {
184
+ prefix: string;
185
+ partyStorage: 'nativeObjectId' | 'opaqueString';
186
+ registry: TemplateRegistry;
187
+ paymentProvider?: PaymentProvider;
188
+ emailProvider?: EmailProvider;
189
+ emailRecipientResolver?: EmailRecipientResolver;
190
+ },
191
+ ) {
192
+ const modelNames = {
193
+ active: `${options.prefix}Message`,
194
+ archive: `${options.prefix}Archive`,
195
+ request: `${options.prefix}Request`,
196
+ user: `${options.prefix}User`,
197
+ };
198
+ const activeSchema = buildMessageSchema({
199
+ connection,
200
+ archiveModelName: modelNames.archive,
201
+ userModelName: modelNames.user,
202
+ emailNotifier: options.emailProvider?.sendNotification.bind(options.emailProvider),
203
+ emailRecipientResolver: options.emailRecipientResolver,
204
+ });
205
+ const archiveSchema = buildMessageArchiveSchema();
206
+ if (options.partyStorage === 'opaqueString') {
207
+ for (const schema of [activeSchema, archiveSchema]) {
208
+ schema.add({
209
+ fromUser: { type: String, default: null, trim: true, minlength: 1 },
210
+ toUser: { type: String, default: null, trim: true, minlength: 1 },
211
+ });
212
+ }
213
+ archiveSchema.add({
214
+ archivedBy: { type: String, default: null, trim: true, minlength: 1, index: true },
215
+ });
216
+ }
217
+ const Message = connection.model(modelNames.active, activeSchema);
218
+ const Archive = connection.model(modelNames.archive, archiveSchema);
219
+ const Request = connection.model(modelNames.request, buildMessageRequestSchema());
220
+ await Promise.all([Message.init(), Archive.init(), Request.init()]);
221
+ const service = new MessageService({
222
+ connection,
223
+ modelNames,
224
+ registry: options.registry,
225
+ paymentProvider: options.paymentProvider,
226
+ });
227
+ return { service, Message, Archive, Request, modelNames };
228
+ }
229
+ ```
230
+
231
+ Create app A with `partyStorage: 'nativeObjectId'`, app B with
232
+ `partyStorage: 'opaqueString'`, distinct `prefix`/connections/registries/providers,
233
+ then mount each with `createMessageRoutes({ service, getUser })`. Each host's
234
+ `getUser(req)` maps its own authenticated request shape to `MessageUser`; list
235
+ through `service.listMessages({ user })` and serialize with `serializePublicMessage`.
236
+ Both apps may use the same `templateCd`, textual requester ID, and `clientRequestId`
237
+ without sharing reservations. Pass the correct registry to direct document
238
+ `archive(actionCd, actorId, registry)`; it is a trusted host primitive, not authorization.
239
+ String storage must cover **both parties and archive `archivedBy`**, with the
240
+ [identity/migration policy below](#user-id-storage-and-normalization-msga-03-migration).
241
+ Transactions still require a replica set/sharded cluster. Idempotent creates skip
242
+ schema email hooks; standalone notifications can use each app's bound notifier and
243
+ [optional custom directory](#recipient-lookup-msga-05).
244
+
245
+ #### Bounded installed-consumer compatibility
246
+
247
+ The packed consumer tests exercise Express **5.0.0** / Mongoose **8.0.0** and the
248
+ exact Express/Mongoose versions installed in this workspace (currently 5.x/9.x).
249
+ Both lanes compile root-import ESM/CommonJS NodeNext and Bundler consumers with
250
+ `strict: true`, `skipLibCheck: false`, and run the two-app MongoDB lifecycle in
251
+ both module formats. The minimum-runtime CI lane executes the installed consumers
252
+ on actual Node **22.0.0**; newer Node may run build/install/TypeScript tooling.
253
+ This bounded check does not certify every intervening or future peer release.
254
+
255
+ MSGA-11 compatibility correction: schema constants now retain explicit portable
256
+ Mongoose schema types, rather than expanding Mongoose 9-only inferred generics in
257
+ the shipped declarations. Active/archive document `_id` is explicitly ObjectId
258
+ across Mongoose 8 and 9. Runtime schemas, bundling, exports and peer/runtime floors
259
+ are unchanged; no data migration is required.
260
+
261
+ Reproduce from the repository after installing build dependencies and Node 22.0.0:
262
+
263
+ ```bash
264
+ mkdir -p _tmp-msga
265
+ git check-ignore _tmp-msga
266
+ export ASDF_NODEJS_VERSION=26.11.1
267
+ export TMPDIR="$PWD/_tmp-msga"
268
+ export MESSAGE_SERVICE_CONSUMER_NODE="$(asdf where nodejs 22.0.0)/bin/node"
269
+ export MESSAGE_SERVICE_EXPECT_NODE=22.0.0
270
+ pnpm --filter @web-ts-toolkit/message-service... build
271
+ pnpm --filter @web-ts-toolkit/message-service exec vitest run --config ../../vitest.config.ts --fileParallelism=false test/message-service.packed-consumer.test.ts -t MSGA-11
272
+ ```
273
+
274
+ Each lane prints exact resolved peers/types/compiler, build-tool and consumer Node,
275
+ MongoDB version and completed checks. `MESSAGE_SERVICE_REPORT_DIR` optionally saves
276
+ JSON evidence outside packed temp consumers. Without the runtime override, local
277
+ tests execute with their test-runner Node, whose version is reported explicitly.
278
+
150
279
  ## API
151
280
 
152
281
  ### `createMessageRoutes(options)`
@@ -155,16 +284,17 @@ Creates a standalone Express router with message template routes. Returns `{ rou
155
284
 
156
285
  All user-facing routes require a resolved user with a valid `_id` (non-empty string or `ObjectId`; missing, null, empty, numeric, array, and plain-object ids return `401`). By default, routes read `req._user || req.user`; pass `authMiddleware` and/or `getUser` to adapt your authentication stack. A request with no resolved user returns `401` before any service, template, payment, model, or action side effect. Hosts remain responsible for installing authentication, permission population, and CSRF protection before mounting these routes.
157
286
 
158
- Custom extractors are preserved: `getUser`, `getPermissions`, and `getIdentity` receive the original Express request and their return values are passed through to `MessageService`. The route factory enforces the shared principal contract: `getUser` must return a user with a non-empty string or `ObjectId` `_id` before continuing.
287
+ Custom extractors are preserved: `getUser`, `getPermissions`, and `getIdentity` receive the original Express request and their return values are passed through to `MessageService`. The route factory enforces the shared principal contract: `getUser` must return a user with a non-empty string or `ObjectId` `_id` and optional `roles` that are undefined or an array of strings before continuing. Malformed roles also return `401` before service/model lookup. See [authenticated principals](#authenticated-principals-msga-02-migration) for getter/hydrated users and the action-handler snapshot.
159
288
 
160
289
  | Option | Type | Description |
161
290
  | ------------------------------ | ------------------------------------------ | --------------------------------------------------------------------------------- |
162
291
  | `getModel` | `(name: string) => Model` | Mongoose model getter |
163
- | `paymentProvider` | `PaymentProvider` | Optional payment provider (enables payment session handling) |
292
+ | `paymentProvider` | `PaymentProvider \| null` | Required for nonempty payment codes; optional for code-free items and replay |
164
293
  | `onPaymentCompensationFailure` | `(event) => void \| Promise<void>` | Optional hook called when expiring an uncommitted payment session fails |
165
294
  | `onTransactionCleanupFailure` | `MessageTransactionCleanupFailureObserver` | Best-effort diagnostics when a service-owned transaction's `endSession()` rejects |
166
- | `adminRoles` | `string[]` | Roles that receive messages when no `toUser`/`toRoles` is specified |
295
+ | `adminRoles` | `string[]` | Fallback roles when `toUser` is absent and `toRoles` is omitted or empty |
167
296
  | `registry` | `TemplateRegistry` | Custom template registry (default: `defaultRegistry`) |
297
+ | `actionLeaseMs` | `number` | Action lease in ms (default: `30000`) |
168
298
  | `authMiddleware` | `((req, res, next) => void)[]` | Custom middleware applied to all routes before route validation |
169
299
  | `getUser` | `(req) => MessageUser \| undefined` | Extract authenticated user (default: `req._user \|\| req.user`) |
170
300
  | `getPermissions` | `(req) => Record<string, boolean>` | Extract permissions (default: `req._permissions \|\| {}`) |
@@ -288,14 +418,15 @@ MongoDB and the payment provider out of band before retrying external effects.
288
418
  Core service for creating messages, getting actions, and handling actions. Constructed by `createMessageRoutes`, or directly:
289
419
 
290
420
  ```typescript
291
- import { MessageService, NoopPaymentProvider, TemplateRegistry } from '@web-ts-toolkit/message-service';
421
+ import { MessageService, TemplateRegistry } from '@web-ts-toolkit/message-service';
292
422
 
293
423
  const registry = new TemplateRegistry();
294
424
  const service = new MessageService({
295
425
  getModel: mongoose.model.bind(mongoose),
296
- paymentProvider: new NoopPaymentProvider(),
426
+ // Add paymentProvider for templates/items with a nonempty paymentCd.
297
427
  adminRoles: ['superadmin'],
298
428
  registry,
429
+ actionLeaseMs: 90_000, // Optional; default is 30000 ms. Expiry permits takeover.
299
430
  });
300
431
  ```
301
432
 
@@ -308,7 +439,7 @@ Methods:
308
439
  - `findMessage(id, { populate?, select? })` — find a message by id (active or archive). Trusted host-level operation: performs no principal validation.
309
440
  - `findMessageOrThrow(id, { populate?, select? })` — same as `findMessage`, but throws `MessageNotFoundError`. Trusted host-level operation.
310
441
  - `getActions(messageId, usertype, { user, permissions?, isAdmin?, populate? })` — read available actions from storage by the requested ID on the configured connection. `user` is required and validated before any model lookup or template operation. Returns `{ uiTemplate, actions }` or `null`; admin/archived views return the resolved `uiTemplate` with an empty list without evaluating conditions. The old `message` option is deprecated and ignored.
311
- - `handleAction(templateCd, actionCd, { message, user, permissions? })` — execute an action with a durable claim, stable handler attempt key, transactional archive, and post-commit sender notification status.
442
+ - `handleAction(templateCd, actionCd, { message, user, permissions?, input?, req? })` — execute an action with a durable claim, optional synchronous pure input parsing, stable handler attempt key, transactional archive, and post-commit sender notification status. Direct input needs no Express request.
312
443
  - `buildVisibilityFilter(user)` — get the Mongoose filter for messages visible to a user.
313
444
 
314
445
  Action-read migration: omit `getActions`' deprecated `message` option. It is
@@ -338,6 +469,260 @@ Pagination contract: `defaultListLimit` and `maxListLimit` must be finite intege
338
469
 
339
470
  `listMessages` uses offset pagination (`skip` + `limit`) for compatibility. Large skips become progressively more expensive because MongoDB still advances over skipped matches before returning the page. Keep `maxListLimit` bounded and prefer a host-owned cursor query using `buildVisibilityFilter(user)` plus the same `{ createdAt: -1, _id: -1 }` ordering for very large inboxes.
340
471
 
472
+ ### Authenticated principals (MSGA-02 migration)
473
+
474
+ `MessageUser` accepts plain objects, prototype getters, and hydrated Mongoose
475
+ users. Its documented fields are `_id`, optional `id`, optional `roles`, and
476
+ optional `displayName`. Equivalent callers receive the same inbox, action, and
477
+ archived-outcome decisions. User-facing service calls (including list/count) and
478
+ routes validate principals before resolving models or running template/provider
479
+ effects. `createMessage` also validates `payerUser` when supplied.
480
+
481
+ - Omitted/undefined `roles` and `[]` are valid and grant no role membership.
482
+ - Provided roles must be an array containing only strings. `null`, strings,
483
+ array-like objects, mixed/non-string elements, and sparse arrays are invalid.
484
+ The service throws `InvalidMessageUserError`; HTTP routes return `401`.
485
+ Invalid roles reject the principal even for direct sender/recipient matches or
486
+ admin read-only views. Document `isSender`/`isReceiver` helpers return `false`
487
+ for malformed roles.
488
+ - Role membership uses exact, case-sensitive string equality. No coercion,
489
+ trimming, partial-array acceptance, or substring matching is applied.
490
+
491
+ Action authorization and `ActionContext.user` explicitly read the four documented
492
+ fields, including getters, and copy the roles array. Action `_id` retains its
493
+ existing normalized string form. No arbitrary user properties, Mongoose internals,
494
+ or private fields are copied, and user-document serializers are not invoked.
495
+ Caller objects are not mutated. Preparation still receives the extracted user
496
+ itself, and callbacks receive the original `req`; custom extractors continue to
497
+ receive that same Express request. Stored targets remain authoritative for active
498
+ claims and archived outcomes.
499
+
500
+ Migration: authentication adapters that supplied string/null/mixed roles must
501
+ return a string array, or omit roles/use `undefined` when absent. Handlers that
502
+ relied on extra spread user properties should obtain explicitly selected host
503
+ data from their original typed request or a template-factory closure. This change
504
+ requires no stored-message migration. Trusted host-level find, notification, and
505
+ direct-archive APIs retain their existing authorization boundaries.
506
+
507
+ ### User-ID storage and normalization (MSGA-03 migration)
508
+
509
+ The default schemas store `fromUser`, `toUser`, and archive `archivedBy` as native
510
+ MongoDB ObjectIds. They work without extra configuration for ObjectId users.
511
+ `UserId` also accepts strings, but arbitrary external/UUID identities need the
512
+ following **complete String-schema recipe**, using package-root named imports:
513
+
514
+ ```typescript
515
+ import type mongoose from 'mongoose';
516
+ import {
517
+ MessageService,
518
+ TemplateRegistry,
519
+ MESSAGE_MODEL_NAME,
520
+ MESSAGE_ARCHIVE_MODEL_NAME,
521
+ MESSAGE_REQUEST_MODEL_NAME,
522
+ buildMessageSchema,
523
+ buildMessageArchiveSchema,
524
+ buildMessageRequestSchema,
525
+ } from '@web-ts-toolkit/message-service';
526
+
527
+ // Call once per connection, before compiling these models. Register the host's
528
+ // trusted templates in registry; use a replica set/sharded cluster for transactions.
529
+ export async function createStringIdMessaging(connection: mongoose.Connection, registry: TemplateRegistry) {
530
+ const activeSchema = buildMessageSchema({ connection }); // email disabled by default
531
+ const archiveSchema = buildMessageArchiveSchema();
532
+ for (const schema of [activeSchema, archiveSchema]) {
533
+ schema.add({
534
+ fromUser: { type: String, default: null, trim: true, minlength: 1 },
535
+ toUser: { type: String, default: null, trim: true, minlength: 1 },
536
+ });
537
+ }
538
+ archiveSchema.add({
539
+ archivedBy: { type: String, default: null, trim: true, minlength: 1, index: true },
540
+ });
541
+ const Message = connection.model(MESSAGE_MODEL_NAME, activeSchema);
542
+ const Archive = connection.model(MESSAGE_ARCHIVE_MODEL_NAME, archiveSchema);
543
+ const Request = connection.model(MESSAGE_REQUEST_MODEL_NAME, buildMessageRequestSchema());
544
+ await Promise.all([Message.init(), Archive.init(), Request.init()]);
545
+ return new MessageService({ connection, registry });
546
+ }
547
+ ```
548
+
549
+ Use principals such as `{ _id: 'oidc|Acme:Alice' }` or a UUID string with this
550
+ service. Template preparation can return those strings in `fromUser`/`toUser`.
551
+ The factory schemas retain methods, hooks, timestamps, and lifecycle/replay
552
+ indexes; overriding only active parties is insufficient because both service
553
+ actions and direct `message.archive()` write archive parties **and `archivedBy`**.
554
+ `MessageRequest` needs no overrides: its owner is already a String scope key.
555
+ Custom model names must also agree between service `modelNames` and active-schema
556
+ `archiveModelName`. If using population or the default email lookup, register a
557
+ User model with a matching String `_id` on this connection and configure the
558
+ appropriate model/ref; no User model is needed without populate when email is
559
+ disabled or a [custom recipient resolver](#recipient-lookup-msga-05) owns lookup.
560
+ Message `_id`, attachment IDs, and the HTTP `:id` contract remain ObjectId.
561
+
562
+ #### Identity policy
563
+
564
+ - User-facing principal strings are trimmed, never lowercased or Unicode-normalized.
565
+ Surrounding whitespace is not identity-significant. Preparation still receives
566
+ the original principal, without mutation. New template messages trim both the
567
+ prepared sender/recipient and the default sender before persistence, including
568
+ direct and idempotent creation; a validated padded sender cannot disappear from
569
+ its own normalized inbox. Service archival stores the normalized actor; direct
570
+ archival trims its validated `archivedBy`. Native ObjectId inputs remain native
571
+ until the configured schema casts them.
572
+ - **Stored native ObjectId:** relationship helpers (including populated `_id`s)
573
+ accept that ObjectId or its 24-hex string in either case, with surrounding padding
574
+ trimmed. This agrees with default Mongoose inbox query casting. Public IDs use
575
+ lowercase hex. The string form in action-handler `user._id` retains the caller's
576
+ trimmed case; it is not a universal canonical business key.
577
+ - **Stored String:** equality is exact and case-sensitive after trimming, even
578
+ for 24-hex strings. `Alice` and `alice`, or uppercase/lowercase hex-looking
579
+ strings, are distinct identities. An ObjectId principal becomes lowercase hex,
580
+ so in a String schema it matches only that exact lowercase identity. Supply
581
+ strings consistently for opaque identities. Use MongoDB's default simple/binary
582
+ collation; case-insensitive collations or custom lowercase/casting setters do not
583
+ satisfy this contract. Each configured collection must use a consistent party
584
+ BSON type; heterogeneous legacy ObjectId/String storage is not supported by this
585
+ recipe without migration.
586
+ - **Request scopes are textual and unchanged:** string requester IDs retain their
587
+ trimmed case, ObjectId requester IDs use lowercase hex. Thus an ObjectId and its
588
+ lowercase hex share a scope; uppercase hex is a **different scope**, even with
589
+ default ObjectId party storage. Relationship equivalence does not merge requests.
590
+ Replays use the exact stored `{ clientRequestOwnerId, templateCd, clientRequestId }`
591
+ across reservations, active messages, and archives. Do not switch representation
592
+ during retries. Completed replay preserves existing stored values and never
593
+ reruns preparation to normalize old messages.
594
+
595
+ The trusted-host `createNotification` boundary still takes raw IDs without
596
+ principal validation or service normalization; the String recipe's schema setters
597
+ trim these and other Mongoose host writes. Direct `archive()` still performs no
598
+ relationship/permission authorization; the host must authorize it. Neither recipe
599
+ adds arbitrary top-level preparation-field persistence. The DTO serializer emits
600
+ stored string case unchanged, ObjectIds as hex, and archive `archivedBy` as an ID;
601
+ it performs no identity repair or authorization.
602
+
603
+ #### Existing records and scope migration
604
+
605
+ This release changes new template party writes and direct archive ownership to
606
+ trim strings, and makes uppercase hex principals agree with stored native ObjectId
607
+ relationships. It does **not** rewrite any record or request scope. No storage
608
+ migration is needed for consistently typed, unpadded records. Do not use IDs whose
609
+ leading/trailing whitespace is significant; they were already incompatible with
610
+ principal validation and inbox query normalization.
611
+
612
+ For legacy padded string parties/ownership, inspect raw BSON in **both** active and
613
+ archive collections (hydration/setters may hide padding), map them to the host's
614
+ authoritative identities, check collisions, then rewrite approved `fromUser`,
615
+ `toUser`, and `archivedBy` values with writers/actions drained. A String-schema
616
+ change alone does not repair persisted padding or change BSON types. Moving an
617
+ ObjectId deployment to external string identities additionally needs a host-owned
618
+ mapping for these three fields, populated User IDs, and relevant host audit/claim
619
+ identity references; blindly stringifying references is not an external-ID mapping.
620
+ Changing principal values also changes requester scopes: apply the coordinated
621
+ scope migration below or retain an exact-scope legacy retry adapter.
622
+
623
+ Prefer keeping the current request-scope convention. If a host changes requester
624
+ values or chooses to canonicalize ObjectId hex strings before service calls, apply
625
+ it consistently at all authentication/service adapters and treat the rollout as a scope migration:
626
+ drain creation/action workers and retries, inventory upper/lowercase (and any
627
+ historically padded/custom) owners for each template/key, reconcile conflicting
628
+ reservations and their **entire** message batches using application knowledge, and
629
+ update `clientRequestOwnerId` consistently in MessageRequest, active, and archive
630
+ records while preserving item indexes/counts and compound unique indexes. Never
631
+ blindly lowercase all owners: opaque IDs may differ only by case, and two completed
632
+ ObjectId-representation scopes may already contain separate business effects.
633
+ If histories cannot be reconciled, retain their old exact-scope retry adapter until
634
+ the host's retention/retry window ends. There is no automatic alias lookup, scope
635
+ merge, or general migration tool in this package.
636
+
637
+ ### Recipient defaults (MSGA-01 migration)
638
+
639
+ For template-based `createMessage`, `adminRoles` (default `[]`) supplies recipients
640
+ only when each prepared item has neither a direct user nor nonempty role recipients:
641
+
642
+ | Prepared `toUser` | Prepared `toRoles` | Stored `toRoles` |
643
+ | --------------------------- | ------------------ | -------------------------- |
644
+ | Absent (`undefined`/`null`) | Omitted or `[]` | Copy of `adminRoles` |
645
+ | Present | Omitted or `[]` | `[]` |
646
+ | Absent or present | Nonempty array | Copy of the explicit array |
647
+
648
+ An explicit empty role array **does not suppress fallback** for an otherwise
649
+ unaddressed item. To create an unaddressed item without fallback roles, configure
650
+ `adminRoles: []` (or omit the option). Explicit `toUser` + `toRoles` combinations
651
+ remain independent recipient grants: the direct user and matching role members
652
+ can see the message and qualify for receiver actions, subject to each action's
653
+ permission and condition checks. Include admin roles explicitly in `toRoles` if
654
+ a directly addressed message should also reach them. Generic `createNotification`
655
+ stores its supplied recipients without applying this template fallback.
656
+
657
+ Migration: new directly addressed template messages no longer implicitly add
658
+ `adminRoles`. Existing active/archive records and completed idempotent replays
659
+ retain their stored recipients. Review legacy `toRoles` in both collections and
660
+ correct unintended grants according to application policy; stored roles govern
661
+ inbox visibility, action authorization, and archived-outcome access. Legacy
662
+ implicit fallback cannot be distinguished automatically from an intentional
663
+ direct-user-plus-role combination.
664
+
665
+ ### Prepared attachments (MSGA-06)
666
+
667
+ The root-exported `PrepareResult.documents?: mongoose.Types.ObjectId[]` selects
668
+ attachments for each fresh `createMessage` item in both direct and idempotent
669
+ creation:
670
+
671
+ | Prepared `documents` | Root message attachments |
672
+ | ---------------------- | ----------------------------------------------------------------- |
673
+ | Nonempty array | The prepared selection |
674
+ | `[]` | No attachments, even when the original payload contains documents |
675
+ | Omitted or `undefined` | Original `PrepareContext.payload.documents`, or `[]` when absent |
676
+
677
+ The fallback always reads the **original caller payload**, even when preparation
678
+ returns a replacement `payload`. A prepared business `payload.documents` does not
679
+ select root attachments. Each selected array is shallow-copied per item, including
680
+ fallback arrays and arrays reused by multiple fan-out items; the service does not
681
+ mutate caller/preparation arrays. Attachment IDs retain the existing ObjectId type.
682
+
683
+ Inject the host's attachment validation/authorization into a template factory. This
684
+ recipe sends approved attachments to reviewers and deliberately clears them for
685
+ an audit copy, without editing the caller's payload:
686
+
687
+ ```typescript
688
+ import type mongoose from 'mongoose';
689
+ import type { MessageTemplate, PrepareContext, PrepareResult } from '@web-ts-toolkit/message-service';
690
+
691
+ export function createAttachmentReviewTemplate(
692
+ selectApprovedDocuments: (ctx: PrepareContext) => Promise<mongoose.Types.ObjectId[]>,
693
+ ): MessageTemplate {
694
+ return {
695
+ templateCd: 'attachment.review',
696
+ type: 'request',
697
+ description: 'Review approved attachments and create an attachment-free audit copy',
698
+ senderContent: { title: 'Sent {{name}}', long: 'Review requested', short: 'Sent' },
699
+ receiverContent: { title: 'Review {{name}}', long: 'Review the request', short: 'Review' },
700
+ uiTemplate: 'default-message',
701
+ prepareMessage: async (ctx): Promise<PrepareResult[]> => {
702
+ const documents = await selectApprovedDocuments(ctx);
703
+ const name = String(ctx.payload.name ?? '');
704
+ return [
705
+ { toRoles: ['reviewer'], documents, templateData: { name }, payload: { name } },
706
+ { toRoles: ['auditor'], documents: [], templateData: { name }, payload: { name } },
707
+ ];
708
+ },
709
+ actions: [],
710
+ };
711
+ }
712
+ ```
713
+
714
+ Content still renders only from `templateData`; business `payload` is stored
715
+ separately and supplies later action labels/confirmations. Document construction
716
+ remains an explicit field allowlist: extra preparation properties cannot override
717
+ message identity, lifecycle, or request ownership.
718
+
719
+ **Adoption/migration:** templates omitting `documents` keep the legacy fallback.
720
+ If preparation must filter/replace attachments, return the approved IDs explicitly;
721
+ if it must remove them, return `documents: []`. Replacing or clearing only the
722
+ business payload does not clear root attachments. Existing active/archive records
723
+ and completed replays keep their stored attachment sets without rerunning
724
+ preparation. No schema/index or automatic stored-data migration is needed.
725
+
341
726
  ### `TemplateRegistry`
342
727
 
343
728
  In-memory registry for trusted message templates.
@@ -358,7 +743,7 @@ registry.clear();
358
743
 
359
744
  The package supports both ESM `import` and CommonJS `require`. Prefer named root imports. `defaultRegistry` is shared through `globalThis` so mixed ESM/CommonJS consumers in one process observe the same mutable registry, but application code should still prefer an explicit `new TemplateRegistry()` per app/test for isolation.
360
745
 
361
- Supported package-root exports are the service (`MessageService`, `isValidMessageUserId`, `requireMessageUserId`, `MAX_MESSAGE_SERVICE_TIMEOUT_MS`), route factory (`createMessageRoutes` plus its `MessageRoutes*` option types), schema factories and model-name constants, template helpers (`TemplateRegistry`, `defaultRegistry`, `includesAction`, `interpolateTemplate`, `interpolateMessageContent`, `resolveUiTemplate`, `filterActions`, `isActionAllowed`, `hasExplicitPermissionGrant`), no-op providers, provider/template/message types including `UserId`, `MessageTemplate`, `RegisteredMessageTemplate`, `RegisteredMessageAction`, `RegisteredUiTemplate`, `IMessage`, `IMessageArchive`, and the typed errors listed below. Deep `dist/*` or `src/*` imports are not part of the public contract. Conditional exports keep working: `import` resolves `./dist/index.mjs` with `./dist/index.d.mts` types, `require` resolves `./dist/index.js` with `./dist/index.d.ts` types.
746
+ Supported package-root exports are the service (`MessageService`, `isValidMessageUserId`, `requireMessageUserId`, `MAX_MESSAGE_SERVICE_TIMEOUT_MS`), route factory (`createMessageRoutes` plus its `MessageRoutes*` option types), schema factories and model-name constants, template helpers (`TemplateRegistry`, `defaultRegistry`, `includesAction`, `interpolateTemplate`, `interpolateMessageContent`, `resolveUiTemplate`, `filterActions`, `isActionAllowed`, `hasExplicitPermissionGrant`), no-op providers, provider/template/message types including `UserId`, `MessageTemplate`, `MessageAction`, `ActionContext`, `PrepareContext`, `PrepareResult`, `RegisteredMessageTemplate`, `RegisteredMessageAction`, `RegisteredUiTemplate`, `IMessage`, `IMessageArchive`, and the typed errors listed below. Deep `dist/*` or `src/*` imports are not part of the public contract. Conditional exports keep working: `import` resolves `./dist/index.mjs` with `./dist/index.d.mts` types, `require` resolves `./dist/index.js` with `./dist/index.d.ts` types.
362
747
 
363
748
  `includesAction(templateCd, actionCd, registry)` requires the registry explicitly so archive behavior never falls back to unrelated global state.
364
749
 
@@ -383,7 +768,7 @@ Rendering/action separation: message content (`senderContent`/`receiverContent`)
383
768
  - The first successful claim stores a stable `actionAttemptId` on the active message and passes it to `action.runHandler(ctx)` as `ctx.actionAttemptId`. If the process dies after an external handler effect but before archival commit, a same-action retry reuses that attempt id. Handlers must use this key with external systems or their own persistence to deduplicate side effects; the service does not claim arbitrary external calls are exactly-once.
384
769
  - If the handler throws before archival commit, the active message moves to `actionState: 'retryable'` with `actionFailureMessage`, and `handleAction()` throws `ActionRetryableError`. A later same-action retry may reclaim the same attempt id; different actions continue to conflict with the outstanding attempt.
385
770
  - Each lease acquisition mints a distinct `actionOwnerToken` while `actionAttemptId` stays stable for external deduplication. The retryable mark, the archive commit, and the post-commit notification-state update are all conditioned on the current owner token: a worker whose lease expired and was taken over receives `ActionConflictError` and cannot change the replacement's claim, archive, or notification state. Archive commits additionally pre-check ownership before inserting and map an archive `_id` conflict to `ActionConflictError`, so exactly one acquisition commits.
386
- - Long-running handlers must complete within the 30-second action lease; there is no lease-renewal API. Fencing guards persisted state only — it cannot stop an already-running external call, so handlers must still deduplicate external effects with `ctx.actionAttemptId`.
771
+ - Configure `actionLeaseMs` for the workload (default `30000` ms). Expiry permits a same-action takeover; it neither cancels the handler nor automatically rejects the still-current owner's completion or failure. Without takeover, that owner can still archive successfully or report a normal retryable failure after expiry. There is no heartbeat or lease-renewal API. Fencing guards persisted state only — it cannot stop an already-running external call, so handlers must still deduplicate external effects with `ctx.actionAttemptId`.
387
772
  - Migration: active records written before fencing have no `actionOwnerToken`. A same-action takeover still matches those records, keeps their attempt id, and mints a token, so no backfill is required. Archives record the committing `actionOwnerToken` alongside `actionAttemptId`.
388
773
  - Archive insertion and active-message deletion run in one MongoDB session transaction using the active and archive models from the service model resolver. If either operation cannot commit, the transaction aborts and no archive-only or active-plus-archive split state is reported as success.
389
774
  - Trusted host-level direct archival (`message.archive(actionCd, archivedBy, registry)`) uses the same atomicity on the document's owning connection: it re-reads persisted state inside the transaction, refuses a live in-flight service claim with `ActionConflictError` (only an explicitly expired `processing` lease or `active`/`retryable` may move), and commits archive insert plus conditional active delete together so concurrent direct archive versus service action cannot double-commit. It performs no sender/receiver, permission, or condition authorization — hosts must authorize before calling and must never expose it to untrusted callers. Invalid action/template/user input throws (`TemplateNotFoundError`/`ActionNotFoundError`, `InvalidMessageUserError`) instead of silently succeeding; repeats throw `MessageArchivedError` (archive exists) or `MessageNotFoundError`; deployments without transaction support throw `MessageTransactionRequiredError` before writing. Migration: callers relying on silent no-op success or on transaction-free standalone archival must handle the new errors and use replica-set transactions.
@@ -391,6 +776,284 @@ Rendering/action separation: message content (`senderContent`/`receiverContent`)
391
776
  - If sender notification fails after the archive commits, the archive is updated to `failed` and `handleAction()` throws `ActionNotificationPendingError` rather than rerunning or reporting the business action as uncommitted. A retry against that archived message returns the same committed-notification-pending outcome.
392
777
  - Crash recovery boundaries are explicit: before claim means another request can claim; after claim but before/during handler means the live claim conflicts until its lease expires; after handler before archive commit means retry must reuse `actionAttemptId`; during archive commit the transaction commits both archive and active delete or rolls both back; after archive commit before notification delivery leaves archived `actionNotificationState: 'pending'`.
393
778
 
779
+ ### Action lease configuration (MSGA-08)
780
+
781
+ `MessageServiceOptions.actionLeaseMs` is an optional finite positive safe integer
782
+ in `[1, MAX_MESSAGE_SERVICE_TIMEOUT_MS]` milliseconds (`2147483647`, the shared
783
+ platform timeout ceiling). The default remains **30000 ms**, used only when omitted
784
+ or explicitly `undefined`. Zero, negative, fractional, `NaN`, infinite, `null`, other
785
+ non-number and above-ceiling values throw
786
+ `InvalidMessageServiceOptionError` at construction, before any model lookup.
787
+ `createMessageRoutes({ getModel, actionLeaseMs, ... })` forwards the same option.
788
+ With `createMessageRoutes({ service })`, configure the supplied service itself;
789
+ combining `service` and `actionLeaseMs` is rejected by strict TypeScript and at runtime.
790
+
791
+ Each fresh claim, retryable same-action retry, or expired same-action takeover
792
+ persists `actionLeaseExpiresAt = actionClaimedAt + actionLeaseMs` using wall-clock
793
+ time. Takeover is eligible at the exact expiry boundary (`<= now`); a different
794
+ action cannot replace an outstanding attempt, even after expiry or handler failure.
795
+ The attempt ID remains stable while each acquisition rotates the private owner
796
+ token. Expiry alone causes no state transition, cancellation, automatic rejection,
797
+ or renewal. A takeover fences the old owner's later completion/failure writes with
798
+ `ActionConflictError`, but running handlers and external calls may overlap.
799
+
800
+ **Migration/configuration:** omitting the option or passing `undefined` preserves
801
+ the existing 30-second policy. Explicit `null` previously defaulted silently and
802
+ now fails at construction; omit the option or use `undefined` to request the default.
803
+ No schema/index/backfill is required. The setting affects each new
804
+ acquisition only, including retries/takeovers; it does not rewrite stored deadlines.
805
+ Configure coordinating service instances consistently and keep their clocks aligned.
806
+ Choose a lease with headroom for the handler and archival transaction to reduce
807
+ overlap; a longer lease also delays recovery after process death. There is no
808
+ heartbeat/renewal mechanism or guarantee that a handler finishes before expiry.
809
+ Continue deduplicating effects by `ctx.actionAttemptId` regardless of duration.
810
+
811
+ ### Action input and host-owned commands (MSGA-15)
812
+
813
+ `handleAction(..., { input })` delivers explicit submitted data as `ActionContext.input`.
814
+ The HTTP action route supplies **`input: req.body`**, with no new envelope, while
815
+ preserving the exact original `req` for handlers, notification callbacks and
816
+ extractors. Direct calls never infer input from `req.body`. Without `parseInput`,
817
+ input passes through unchanged, including undefined, null, arrays and scalars;
818
+ HTTP receives whatever the host/body-parser middleware supplies (an absent wire
819
+ body may become `{}`). No package-level command schema or deep freeze is implied.
820
+
821
+ An action can opt into `parseInput: (raw: unknown) => unknown`. It runs once per
822
+ acquisition **after atomic claim and authoritative authorization/current action
823
+ selection**, before the handler. Return a fresh normalized, allowlisted command;
824
+ treat raw input as read-only. Parsing must be synchronous, deterministic and
825
+ effect-free. **Async parsers are not supported**: Promise/thenable outputs fail
826
+ as server configuration errors, their rejections are consumed, and no handler
827
+ runs. Purity is a trusted-template contract, not a sandbox. Parsers never run for
828
+ unauthorized callers, creation, any action listing (including admin/archive),
829
+ or terminal/pending-notification archived outcomes.
830
+
831
+ - Throw root-exported `ActionInputValidationError` for invalid submitted input.
832
+ HTTP returns 400 with fixed `invalid action input`, without diagnostic text,
833
+ causes or attempt IDs. Unexpected parser failures return sanitized 500; direct
834
+ callers retain the original diagnostic as `error.cause`.
835
+ - Either parser failure uses owner-fenced pre-handler release. Fresh claims
836
+ return to `active` and clear all reservation fields, allowing corrected input
837
+ or a different allowed action. Retry/takeover claims become `retryable` with
838
+ their stable attempt ID/action retained: an earlier owner may have had effects.
839
+ A lost-owner release leaves the replacement untouched and reports this caller's
840
+ error; other release failures remain operational failures.
841
+ - Errors **inside `runHandler`**, including `ActionInputValidationError` or host
842
+ command conflicts, keep the ordinary `ActionRetryableError` contract (HTTP 409).
843
+ Successful actions still archive terminally (later HTTP 410 or notification-
844
+ pending 202); there is no new package result-replay API.
845
+
846
+ `action.payload` and confirmation fields (`requireNotes`, `documents`, etc.) are
847
+ presentation hints. `message.payload` contains persisted business targets and
848
+ condition data. Neither is submitted input or an enforced validation schema.
849
+ Hosts own body-parser size limits, direct-call bounds, runtime/schema validation,
850
+ attachment access and resource-specific permissions; recheck domain grants on
851
+ retries **before returning cached business results**.
852
+
853
+ The concrete example below captures a business service in a template closure.
854
+ It requires a fresh Mongoose connection to a transaction-capable MongoDB replica
855
+ set/sharded cluster, with the package's Node/peer baseline. A unique `_id` and
856
+ insert-only upsert reserve an immutable versioned command under `actionAttemptId`.
857
+ The host transaction couples only its local business mutation and durable result;
858
+ package archival is a separate transaction. This sample also binds the actor.
859
+
860
+ ```typescript
861
+ import mongoose from 'mongoose';
862
+ import {
863
+ ActionInputValidationError,
864
+ MessageService,
865
+ TemplateRegistry,
866
+ buildMessageSchema,
867
+ buildMessageArchiveSchema,
868
+ buildMessageRequestSchema,
869
+ MESSAGE_MODEL_NAME,
870
+ MESSAGE_ARCHIVE_MODEL_NAME,
871
+ MESSAGE_REQUEST_MODEL_NAME,
872
+ type MessageTemplate,
873
+ } from '@web-ts-toolkit/message-service';
874
+
875
+ type RejectInput = { notes: string };
876
+ type RejectCommand = RejectInput & {
877
+ attemptId: string;
878
+ messageId: string;
879
+ approvalId: string;
880
+ actorId: string;
881
+ };
882
+
883
+ export class HostCommandConflictError extends Error {}
884
+
885
+ export function parseRejectInput(raw: unknown): RejectInput {
886
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new ActionInputValidationError();
887
+ const notes = (raw as { notes?: unknown }).notes;
888
+ if (
889
+ Object.keys(raw).some((key) => key !== 'notes') ||
890
+ typeof notes !== 'string' ||
891
+ notes.length > 2000 ||
892
+ !notes.trim()
893
+ ) {
894
+ throw new ActionInputValidationError();
895
+ }
896
+ return { notes: notes.trim() };
897
+ }
898
+
899
+ export function createRejectionWorkflow(connection: mongoose.Connection) {
900
+ const Approval = connection.model(
901
+ 'ReviewApproval',
902
+ new mongoose.Schema({
903
+ reviewer: { type: String, required: true },
904
+ state: { type: String, default: 'pending' },
905
+ notes: { type: String, default: '' },
906
+ effectCount: { type: Number, default: 0 },
907
+ }),
908
+ );
909
+ const Command = connection.model(
910
+ 'ReviewRejectCommand',
911
+ new mongoose.Schema({
912
+ _id: { type: String, required: true }, // MongoDB's unique _id index
913
+ canonical: { type: String, required: true },
914
+ notes: { type: String, required: true },
915
+ result: { type: mongoose.Schema.Types.Mixed, default: null },
916
+ }),
917
+ );
918
+
919
+ async function reject(c: RejectCommand) {
920
+ if (!(await Approval.exists({ _id: c.approvalId, reviewer: c.actorId })))
921
+ throw new Error('domain permission denied');
922
+ // Versioned, allowlisted tuple binds message, action/template, target and actor.
923
+ const canonical = JSON.stringify([
924
+ 1,
925
+ 'approval-review-v1',
926
+ 'reject',
927
+ c.messageId,
928
+ c.approvalId,
929
+ c.actorId,
930
+ c.notes,
931
+ ]);
932
+ try {
933
+ await Command.updateOne({ _id: c.attemptId }, { $setOnInsert: { canonical, notes: c.notes } }, { upsert: true });
934
+ } catch (error) {
935
+ if (!(error instanceof Error && 'code' in error && error.code === 11000)) throw error;
936
+ // A concurrent insert won; compare its durable original in the transaction.
937
+ }
938
+ return connection.transaction(async (session) => {
939
+ // Reauthorize even cached-result reads. Only local MongoDB writes here.
940
+ const approval = await Approval.findOne({ _id: c.approvalId, reviewer: c.actorId }).session(session).lean();
941
+ if (!approval) throw new Error('domain permission denied');
942
+ const stored = await Command.findById(c.attemptId).session(session).lean();
943
+ if (!stored) throw new Error('command missing');
944
+ if (stored.canonical !== canonical) throw new HostCommandConflictError();
945
+ if (stored.result) return stored.result as { rejected: true; notes: string };
946
+ if (approval.state !== 'pending') throw new Error('approval already decided');
947
+ const changed = await Approval.updateOne(
948
+ { _id: c.approvalId, reviewer: c.actorId, state: 'pending' },
949
+ { $set: { state: 'rejected', notes: stored.notes }, $inc: { effectCount: 1 } },
950
+ { session },
951
+ );
952
+ if (changed.matchedCount !== 1) throw new Error('approval changed');
953
+ const result = { rejected: true as const, notes: stored.notes };
954
+ const completed = await Command.updateOne(
955
+ { _id: c.attemptId, canonical, result: null },
956
+ { $set: { result } },
957
+ { session },
958
+ );
959
+ if (completed.matchedCount !== 1) throw new Error('command changed');
960
+ return result;
961
+ });
962
+ }
963
+
964
+ function template(approvalId: string, reviewer: mongoose.Types.ObjectId): MessageTemplate {
965
+ return {
966
+ templateCd: 'approval-review-v1',
967
+ type: 'request',
968
+ description: 'Review a host-authorized target',
969
+ senderContent: { title: 'Sent', long: 'Review requested', short: 'Sent' },
970
+ receiverContent: { title: 'Review', long: 'Please review', short: 'Review' },
971
+ uiTemplate: 'card',
972
+ prepareMessage: async ({ user }) => ({ fromUser: user._id, toUser: reviewer, payload: { approvalId } }),
973
+ actions: [
974
+ {
975
+ actionCd: 'reject',
976
+ name: 'Reject',
977
+ variant: 'danger',
978
+ sender: false,
979
+ receiver: true,
980
+ confirmation: { title: 'Reject', message: 'Reject request?', requireNotes: true },
981
+ parseInput: parseRejectInput,
982
+ async runHandler({ message, user, actionAttemptId, input }) {
983
+ if (!actionAttemptId) throw new Error('attempt missing');
984
+ return reject({
985
+ // Business service captured by this template closure.
986
+ attemptId: actionAttemptId,
987
+ messageId: String(message._id),
988
+ approvalId: String(message.payload.approvalId), // authoritative claimed target
989
+ actorId: String(user._id),
990
+ notes: (input as RejectInput).notes, // this action's parser output
991
+ });
992
+ },
993
+ },
994
+ ],
995
+ };
996
+ }
997
+ return { Approval, Command, reject, template };
998
+ }
999
+
1000
+ // Executable local example on a fresh connection; a real host supplies its
1001
+ // authorized existing approval/reviewer instead of creating this fixture target.
1002
+ export async function rejectApprovalExample(connection: mongoose.Connection) {
1003
+ const Message = connection.model(MESSAGE_MODEL_NAME, buildMessageSchema());
1004
+ const Archive = connection.model(MESSAGE_ARCHIVE_MODEL_NAME, buildMessageArchiveSchema());
1005
+ const Request = connection.model(MESSAGE_REQUEST_MODEL_NAME, buildMessageRequestSchema());
1006
+ const business = createRejectionWorkflow(connection);
1007
+ await Promise.all([
1008
+ Message.init(),
1009
+ Archive.init(),
1010
+ Request.init(),
1011
+ business.Approval.init(),
1012
+ business.Command.init(),
1013
+ ]);
1014
+ const reviewer = new mongoose.Types.ObjectId();
1015
+ const approval = await business.Approval.create({ reviewer: String(reviewer) });
1016
+ const registry = new TemplateRegistry();
1017
+ registry.register(business.template(String(approval._id), reviewer));
1018
+ const service = new MessageService({ connection, registry });
1019
+ const [message] = await service.createMessage({
1020
+ templateCd: 'approval-review-v1',
1021
+ user: { _id: new mongoose.Types.ObjectId() },
1022
+ });
1023
+ return service.handleAction(message.templateCd, 'reject', {
1024
+ message,
1025
+ user: { _id: reviewer },
1026
+ input: { notes: ' Original reason ' }, // no req / Express dependency
1027
+ });
1028
+ }
1029
+ ```
1030
+
1031
+ **First durable host reservation wins**, not the first package claim/request. If
1032
+ an owner dies before reserving, a later authorized owner may reserve a different
1033
+ valid command under that attempt. Once reserved, equivalent normalized input
1034
+ replays the original result, including after archive failure or process restart;
1035
+ changed input produces a controlled handler-entered 409 without replacing command
1036
+ or result or repeating the mutation. Missing required input is invalid, never an
1037
+ implicit cached-result lookup. Keep records/results for the entire retry window,
1038
+ with supported versioned decoders/handlers; avoid hashing arbitrary raw request JSON.
1039
+
1040
+ **MSGA-15 migration:** existing `req` handlers continue to work. Move command reads
1041
+ to validated `ctx.input`; reading changed `req.body` alongside it defeats stability.
1042
+ The example is for a new template population. Inventory/drain existing processing/
1043
+ retryable attempts or retain an explicit legacy path; recover known originals only
1044
+ from trusted host records after effect reconciliation. Unknown legacy originals
1045
+ need host review, not first-seen-body backfill, cleared claims or new attempt IDs.
1046
+ There is no new package snapshot, schema/index migration, or legacy classification
1047
+ flag. Actor changes and command schema changes require an explicit host policy.
1048
+
1049
+ For external effects, send the stable attempt key **and immutable original command**
1050
+ to a downstream system that durably deduplicates concurrent owners and ambiguous
1051
+ retries. Recording a result only after a remote call leaves a crash gap. External
1052
+ calls must stay outside automatically retried MongoDB transaction callbacks; an
1053
+ outbox still needs downstream deduplication or explicit reconciliation. Neither
1054
+ the package nor this local example guarantees exactly-once remote execution,
1055
+ cancels stale workers, or jointly commits business writes with the package archive.
1056
+
394
1057
  ### Schema factories
395
1058
 
396
1059
  ```typescript
@@ -408,7 +1071,7 @@ const MessageArchive = mongoose.model(MESSAGE_ARCHIVE_MODEL_NAME, buildMessageAr
408
1071
  const MessageRequest = mongoose.model(MESSAGE_REQUEST_MODEL_NAME, buildMessageRequestSchema());
409
1072
  ```
410
1073
 
411
- `buildMessageSchema({ emailNotifier, emailNotificationExclusions, onEmailDeliveryFailure, userModelName, archiveModelName })` lets you opt in to email notifications with per-template exclusions. When `emailNotifier` is `null` (the default), no email hook is registered.
1074
+ `buildMessageSchema({ connection, emailNotifier, emailRecipientResolver, emailNotificationExclusions, onEmailDeliveryFailure, userModelName, archiveModelName })` lets you opt in to email notifications with rendered-title exclusions. When `emailNotifier` is omitted or `null` (the default), no email hook is registered and a supplied resolver is never called.
412
1075
 
413
1076
  Email delivery is best-effort, not durable. The schema sends only for newly created messages after a successful non-transactional save. It deliberately skips documents saved with a Mongoose session because Mongoose save hooks run before the surrounding transaction commits; this package does not include a durable email outbox or retry worker. Hosts that need reliable transactional email should write their own outbox record in the same transaction and deliver from that outbox after commit.
414
1077
 
@@ -416,6 +1079,15 @@ Email lookup and notifier failures do not roll back a committed message. Pass `o
416
1079
 
417
1080
  Email exclusion matching uses the rendered receiver title after trim + lowercase normalization. The title passed to the notifier is trimmed but keeps its original case. Existing message updates do not resend email; change-triggered or manual resend workflows should be implemented explicitly by the host application.
418
1081
 
1082
+ The default recipient adapter resolves `userModelName` (default `'User'`) on the
1083
+ hydrated message's owning connection and calls
1084
+ `findById(toUser).select('email').lean()`. Register that model on the same connection
1085
+ before building the schema, and pass `connection` for its eager registration check.
1086
+ Runtime lookup always follows the document's connection, even if the schema was
1087
+ built against another connection. A missing user or missing/empty `email` skips
1088
+ delivery. To use nested addresses, preferences, or an external directory, configure
1089
+ [`emailRecipientResolver`](#recipient-lookup-msga-05) instead.
1090
+
419
1091
  When you use `clientRequestId`, register all three models. `MessageRequest` stores the reservation record that prevents duplicate side effects during concurrent duplicate requests in the same scope.
420
1092
 
421
1093
  ### Idempotent create notes
@@ -441,6 +1113,7 @@ import {
441
1113
  TemplateNotFoundError,
442
1114
  ActionNotFoundError,
443
1115
  ActionNotAllowedError,
1116
+ ActionInputValidationError,
444
1117
  ActionConflictError,
445
1118
  ActionRetryableError,
446
1119
  ActionNotificationPendingError,
@@ -451,6 +1124,7 @@ import {
451
1124
  ClientRequestFailedError,
452
1125
  ClientRequestInconsistentStateError,
453
1126
  MessageTransactionRequiredError,
1127
+ PaymentProviderRequiredError,
454
1128
  PaymentSessionCompensationError,
455
1129
  PaymentSessionCompensationAggregateError,
456
1130
  MessageArchivedError,
@@ -469,8 +1143,9 @@ The package brands exported errors so `instanceof` works across mixed ESM/Common
469
1143
  | `TemplateNotFoundError` | Requested `templateCd` is not registered | 404 |
470
1144
  | `ActionNotFoundError` | Requested action is not present on the message template | 404 |
471
1145
  | `ActionNotAllowedError` | User lacks sender/receiver, permission, or condition access | 403 |
1146
+ | `ActionInputValidationError` | Pure pre-handler parser rejected this submission; fresh claims release, retry attempts retain their ID/action | 400, fixed `invalid action input` |
472
1147
  | `MessageArchivedError` | Mutation was requested for an archived message | 410 |
473
- | `InvalidMessageUserError` | Missing/invalid user identity at the service boundary | 401 |
1148
+ | `InvalidMessageUserError` | Missing/invalid user identity or malformed principal roles at the service boundary | 401 |
474
1149
  | `InvalidClientRequestIdError` | `clientRequestId` is not a valid bounded string | 400 |
475
1150
  | `ActionConflictError` | Another live action attempt owns the message | 409 |
476
1151
  | `ActionRetryableError` | Handler failed before archive commit; same action may retry with the persisted `actionAttemptId` | 409 |
@@ -480,9 +1155,10 @@ The package brands exported errors so `instanceof` works across mixed ESM/Common
480
1155
  | `ClientRequestInconsistentStateError` | Persisted reservation/message state is corrupt | propagated as a server error |
481
1156
  | `MessageTransactionRequiredError` | MongoDB deployment cannot provide required idempotent batch transactions | propagated as a server error |
482
1157
  | `InvalidPaginationValueError` | Invalid service construction or list pagination input | propagated as a server error unless handled by host code |
483
- | `InvalidMessageServiceOptionError` | Invalid `clientRequestLeaseMs`/`clientRequestWaitMs`/`clientRequestPollMs` (non-finite, fractional, out-of-range, or timer-overflow) | propagated as a server error unless handled by host code |
1158
+ | `InvalidMessageServiceOptionError` | Invalid `actionLeaseMs`/`clientRequestLeaseMs`/`clientRequestWaitMs`/`clientRequestPollMs` (non-finite, fractional, out-of-range, or timer-overflow) | propagated as a server error unless handled by host code |
484
1159
  | `ActionTemplateMismatchError` | Direct `handleAction()` template argument does not match the message | propagated as a server error unless handled by host code |
485
1160
  | `MessageModelResolutionError` | Configured active/archive/request/user model cannot be resolved | propagated as a server error |
1161
+ | `PaymentProviderRequiredError` | A fresh item has a nonempty effective `paymentCd` but the service provider is absent/null; trusted server configuration must be corrected | 500, sanitized `Internal Server Error` |
486
1162
  | `PaymentSessionCompensationError` | Create failed and at least one uncommitted payment session could not be expired (single session; also the base class of the aggregate below) | propagated as a server error |
487
1163
  | `PaymentSessionCompensationAggregateError` | Multi-session create failed and at least one uncommitted payment session could not be expired; `failures` carries every per-session `{ sessionId, compensationError, hookError }` with the triggering error as `originalError` | propagated as a server error |
488
1164
  | `TemplateRegistryValidationError` | Invalid trusted template metadata at registration time | not thrown by route handlers |
@@ -509,8 +1185,154 @@ class SendGridEmailProvider implements EmailProvider {
509
1185
 
510
1186
  Pass it to `buildMessageSchema({ emailNotifier: provider.sendNotification.bind(provider), onEmailDeliveryFailure })`.
511
1187
 
1188
+ #### Recipient lookup (MSGA-05)
1189
+
1190
+ `emailRecipientResolver?: EmailRecipientResolver` replaces the default User.email
1191
+ adapter. It receives `EmailRecipientResolverContext` with:
1192
+
1193
+ - `recipientId`: the direct `toUser` after schema casting (ObjectId by default,
1194
+ opaque string with the [String-ID recipe](#user-id-storage-and-normalization-msga-03-migration)).
1195
+ - `connection`: the hydrated message's owning Mongoose connection.
1196
+ - `message`: saved fields, including `_id`, `templateCd`, `payload`, `display`, and
1197
+ rendered content. This is a shallow read-only view of the hydrated document, not
1198
+ a cloned/frozen snapshot; do not mutate it or nested data. Its type exposes no
1199
+ document methods.
1200
+
1201
+ The resolver can return a string synchronously or asynchronously. `null`,
1202
+ `undefined`, and `''` skip delivery without a failure event or default lookup.
1203
+ Nonempty addresses are forwarded unchanged: the host owns validation, normalization,
1204
+ and preference decisions. A custom resolver needs **no local User model**, and
1205
+ `userModelName`/its eager check are ignored. Other model registration needed for
1206
+ host population or the resolver's own queries remains the host's responsibility.
1207
+
1208
+ For a nested contact and an opt-out preference, register the host's `Contact` model
1209
+ on the message connection, then pass this resolver to `buildMessageSchema` along
1210
+ with a notifier:
1211
+
1212
+ ```typescript
1213
+ import type { EmailRecipientResolver } from '@web-ts-toolkit/message-service';
1214
+
1215
+ export const resolveNestedEmail: EmailRecipientResolver = async ({ connection, recipientId }) => {
1216
+ const Contact = connection.model<{ contact?: { email?: string; notifications?: boolean } }>('Contact');
1217
+ const recipient = await Contact.findById(recipientId).select('contact').lean();
1218
+ return recipient?.contact?.notifications ? recipient.contact.email : null;
1219
+ };
1220
+ ```
1221
+
1222
+ For an external directory, inject the host client and provider. This schema factory
1223
+ registers no User model; compile it on `connection` as usual. Bind both methods if
1224
+ they depend on their instance's `this`:
1225
+
1226
+ ```typescript
1227
+ import type mongoose from 'mongoose';
1228
+ import {
1229
+ buildMessageSchema,
1230
+ type EmailProvider,
1231
+ type EmailRecipientResolver,
1232
+ type MessageSchemaConfig,
1233
+ } from '@web-ts-toolkit/message-service';
1234
+
1235
+ export interface ContactDirectory {
1236
+ resolveEmail: EmailRecipientResolver;
1237
+ }
1238
+
1239
+ export function buildDirectoryMessageSchema(
1240
+ connection: mongoose.Connection,
1241
+ directory: ContactDirectory,
1242
+ provider: EmailProvider,
1243
+ onEmailDeliveryFailure?: MessageSchemaConfig['onEmailDeliveryFailure'],
1244
+ ) {
1245
+ return buildMessageSchema({
1246
+ connection,
1247
+ emailRecipientResolver: directory.resolveEmail.bind(directory),
1248
+ emailNotifier: provider.sendNotification.bind(provider),
1249
+ onEmailDeliveryFailure,
1250
+ });
1251
+ }
1252
+ ```
1253
+
1254
+ Resolver throws/rejections report `onEmailDeliveryFailure` stage `recipientLookup`;
1255
+ notifier failures report stage `notifier`. Events retain the original `error`,
1256
+ `messageId`, `recipientId`, and trimmed `title`. Both failures occur after persistence
1257
+ and leave the save successful, even if the observer throws/rejects. All callbacks
1258
+ are awaited, so external lookups/delivery/observers should have host timeouts.
1259
+
1260
+ Lookup runs only for new successful saves with a direct recipient and nonblank,
1261
+ nonexcluded rendered receiver title. Role-only messages do not fan out to email.
1262
+ Disabled email, excluded/blank titles, updates, and **all session-bound saves** skip
1263
+ both resolver and notifier (including sessions outside transactions and committed
1264
+ transactions). Idempotent creation uses transactions and therefore also skips email;
1265
+ completed replay never resends. Body selection remains the longer rendered receiver
1266
+ `long`/`short` value (short wins ties). For reliable transactional delivery, use a
1267
+ host-owned outbox; this hook has no durable retry.
1268
+
1269
+ **Migration:** the resolver is optional; omitting it preserves the existing local
1270
+ User.email adapter, eager registration check, timing, and failure behavior. To
1271
+ replace a dummy User registration, configure a custom resolver before compiling
1272
+ the Message model. Address resolution does not change party storage, population,
1273
+ message recipients, or action permissions and requires no data/index migration.
1274
+
512
1275
  ### Payment Provider
513
1276
 
1277
+ #### Required sessions (MSGA-14 migration)
1278
+
1279
+ Every fresh prepared item with a nonempty effective `paymentCd` **must create a
1280
+ session**, including messages with no direct recipient, no roles, and no fallback
1281
+ roles. The effective code remains `item.paymentCd || template.paymentCd || ''`:
1282
+
1283
+ | Template code | Prepared item code | Effective code / behavior |
1284
+ | --------------- | ---------------------- | -------------------------------------------------------- |
1285
+ | Omitted or `''` | Omitted or `''` | `''`; no provider call, null session |
1286
+ | Nonempty `T` | Omitted or `''` | `T`; an empty override cannot disable the default |
1287
+ | Any | Nonempty `I` | `I`; item overrides the template |
1288
+ | Any | Whitespace-only string | Exact string; codes are not trimmed or grammar-validated |
1289
+
1290
+ Only trusted preparation can set an item code; a raw `payload.paymentCd` does not
1291
+ override it unless preparation explicitly maps it. One session is requested per
1292
+ paid item, not per recipient. The payer is `(payerUser || user)._id`, independently
1293
+ of prepared `fromUser`/`toUser`/roles; HTTP uses the authenticated user as payer.
1294
+ Only that item's `priceArgs` is forwarded.
1295
+
1296
+ | Provider | Nonempty effective code, with any recipients or none |
1297
+ | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- |
1298
+ | Omitted/undefined or `null` | `PaymentProviderRequiredError` before this item's write; HTTP 500 with sanitized `Internal Server Error` |
1299
+ | `NoopPaymentProvider`, or any provider returning `null`/`''` | Existing `Error('payment session creation failed')`; no item write |
1300
+ | Provider rejects/throws | Original rejection propagates, unless compensation itself fails; existing route error mappings still apply |
1301
+ | Provider returns a nonempty session ID | Store that session if persistence succeeds |
1302
+
1303
+ The provider remains optional for code-free items, which skip it even if it would
1304
+ throw. `NoopPaymentProvider` cannot satisfy a paid code and is not an implicit
1305
+ default. A paid template that prepares `null` or `[]` has no items to pay for.
1306
+
1307
+ Checks run per fresh item after completed replay selection. Direct batches persist
1308
+ sequentially: a later configuration/provider failure stops the batch and retains
1309
+ the already-committed prefix and its sessions. Idempotent batches commit all items
1310
+ and reservation completion atomically; on failure every known uncommitted session
1311
+ is compensated. Sessions are created outside retryable transaction callbacks.
1312
+ A failed reservation remains failed after provider configuration is repaired;
1313
+ same-key retries return `ClientRequestFailedError` (HTTP 409). Reconcile retained
1314
+ items/provider effects before starting a new operation with a new key.
1315
+
1316
+ Completed same-scope replays return current active/archive records in item order,
1317
+ including legacy nonempty-code/null-session records, without preparation or payment
1318
+ work. They remain valid with absent/null/no-op/throwing providers and after template
1319
+ replacement/removal or payload/payer changes. Principal/payer validation still runs.
1320
+
1321
+ **Migration:** fresh code-bearing items that previously committed sessionless with
1322
+ an absent/null provider now fail; configured providers also run for unaddressed
1323
+ items that previously skipped them. Configure a real provider when checkout is
1324
+ intended. Move metadata-only codes into `payload` or `display` and remove their
1325
+ template/item `paymentCd`. For mixed free/paid items, omit a template default and
1326
+ assign per-item paid codes; `''` cannot clear a paid default. Audit existing
1327
+ nonempty-code/null-session active/archive records using host business knowledge.
1328
+ No schema, index, request-scope, or automatic data migration is needed: do not
1329
+ automatically backfill sessions, charge, rewrite legacy codes, invalidate completed
1330
+ reservations, or reinterpret existing actions. Nullable session types remain valid
1331
+ for code-free and legacy records.
1332
+
1333
+ Session creation is not proof of payment. Completion, capture, refunds/webhooks,
1334
+ and payment-based business authorization remain host/provider responsibilities.
1335
+
514
1336
  ```typescript
515
1337
  import { type PaymentProvider, type UserId } from '@web-ts-toolkit/message-service';
516
1338