@web-ts-toolkit/message-service 0.42.2 → 0.47.1

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 +230 -57
  2. package/index.d.mts +696 -111
  3. package/index.d.ts +696 -111
  4. package/index.js +1382 -686
  5. package/index.mjs +1363 -676
  6. package/package.json +3 -3
package/README.md CHANGED
@@ -13,6 +13,8 @@ Peer dependencies:
13
13
  - `mongoose >= 8`
14
14
  - `express >= 5`
15
15
 
16
+ Bundling note: `handlebars` and the workspace deps are bundled (`tsup` externals are only `mongoose`/`express`), so only `mongoose` + `express` are peers.
17
+
16
18
  ## Highlights
17
19
 
18
20
  - **Message persistence schemas** — `Message`, `MessageArchive`, and `MessageRequest` schemas with timestamps, indexes, archiving, and idempotency reservations
@@ -76,6 +78,9 @@ const myTemplate: MessageTemplate = {
76
78
  fromUser: user._id,
77
79
  toRoles: ['reviewer'],
78
80
  payload,
81
+ // Content (`senderContent`/`receiverContent`) renders from `templateData`,
82
+ // not from `payload`. Map caller input here so `{{name}}` renders.
83
+ templateData: { name: String(payload.name ?? '') },
79
84
  }),
80
85
  actions: [
81
86
  {
@@ -97,6 +102,37 @@ const { router, service } = createMessageRoutes({
97
102
  app.use('/api/messages', myAuthMiddleware, router.original);
98
103
  ```
99
104
 
105
+ Prerequisites for the flow above: Node `>=22`, `mongoose >= 8`, `express >= 5`,
106
+ and a MongoDB replica set (or sharded cluster) when you use `clientRequestId`
107
+ idempotency or transactional archival — standalone servers throw
108
+ `MessageTransactionRequiredError` instead of partially persisting. The auth
109
+ middleware above is required: routes resolve `req._user || req.user` and return
110
+ `401` before any service, template, payment, model, or action effect when the
111
+ identity is missing or malformed.
112
+
113
+ Create a message and act on it (content renders from `templateData`):
114
+
115
+ ```typescript
116
+ const senderId = new mongoose.Types.ObjectId();
117
+ const reviewerId = new mongoose.Types.ObjectId();
118
+ const created = await service.createMessage({
119
+ templateCd: 'welcome.request',
120
+ user: { _id: senderId },
121
+ payload: { name: 'Ada' },
122
+ });
123
+ // `created` is `Array<IMessage | IMessageArchive>`; fresh creates resolve
124
+ // active documents only, while completed `clientRequestId` replays may mix in
125
+ // `IMessageArchive` records (narrow with `'archivedAt' in doc`).
126
+ const message = created[0];
127
+ if (!message || 'archivedAt' in message) throw new Error('expected an active message');
128
+ console.log(message.receiverContent.title); // 'Review Ada'
129
+
130
+ const listed = await service.getActions(String(message._id), 'receiver', {
131
+ user: { _id: reviewerId, roles: ['reviewer'] },
132
+ });
133
+ console.log(listed?.actions.map((action) => action.actionCd)); // ['approve']
134
+ ```
135
+
100
136
  For per-app template isolation, pass a custom `TemplateRegistry`:
101
137
 
102
138
  ```typescript
@@ -117,22 +153,23 @@ const { router, service } = createMessageRoutes({
117
153
 
118
154
  Creates a standalone Express router with message template routes. Returns `{ router, service }`. Mount the returned JsonRouter with `router.original`.
119
155
 
120
- All user-facing routes require a resolved user with a non-empty `_id`. 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.
156
+ 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.
121
157
 
122
- 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 only validates that `getUser` returns a user with a non-empty `_id` before continuing.
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.
123
159
 
124
- | Option | Type | Description |
125
- | ------------------------------ | ----------------------------------- | -------------------------------------------------------------------------- |
126
- | `getModel` | `(name: string) => Model` | Mongoose model getter |
127
- | `paymentProvider` | `PaymentProvider` | Optional payment provider (enables payment session handling) |
128
- | `onPaymentCompensationFailure` | `(event) => void \| Promise<void>` | Optional hook called when expiring an uncommitted payment session fails |
129
- | `adminRoles` | `string[]` | Roles that receive messages when no `toUser`/`toRoles` is specified |
130
- | `registry` | `TemplateRegistry` | Custom template registry (default: `defaultRegistry`) |
131
- | `authMiddleware` | `((req, res, next) => void)[]` | Custom middleware applied to all routes before route validation |
132
- | `getUser` | `(req) => MessageUser \| undefined` | Extract authenticated user (default: `req._user \|\| req.user`) |
133
- | `getPermissions` | `(req) => Record<string, boolean>` | Extract permissions (default: `req._permissions \|\| {}`) |
134
- | `getIdentity` | `(req) => Record<string, unknown>` | Extract identity (default: `req._identity \|\| {}`) |
135
- | `adminPermissionKey` | `string` | Permission key for admin read-only view of actions (default: `'is.admin'`) |
160
+ | Option | Type | Description |
161
+ | ------------------------------ | ------------------------------------------ | --------------------------------------------------------------------------------- |
162
+ | `getModel` | `(name: string) => Model` | Mongoose model getter |
163
+ | `paymentProvider` | `PaymentProvider` | Optional payment provider (enables payment session handling) |
164
+ | `onPaymentCompensationFailure` | `(event) => void \| Promise<void>` | Optional hook called when expiring an uncommitted payment session fails |
165
+ | `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 |
167
+ | `registry` | `TemplateRegistry` | Custom template registry (default: `defaultRegistry`) |
168
+ | `authMiddleware` | `((req, res, next) => void)[]` | Custom middleware applied to all routes before route validation |
169
+ | `getUser` | `(req) => MessageUser \| undefined` | Extract authenticated user (default: `req._user \|\| req.user`) |
170
+ | `getPermissions` | `(req) => Record<string, boolean>` | Extract permissions (default: `req._permissions \|\| {}`) |
171
+ | `getIdentity` | `(req) => Record<string, unknown>` | Extract identity (default: `req._identity \|\| {}`) |
172
+ | `adminPermissionKey` | `string` | Permission key for admin read-only view of actions (default: `'is.admin'`) |
136
173
 
137
174
  Routes:
138
175
 
@@ -150,17 +187,113 @@ Route input validation happens before database or template lookup:
150
187
 
151
188
  HTTP contract change: action mutation is POST-only. `GET /:id/action/:actionCd` is not registered and must not be used for state changes; callers should treat a GET response as not found or method-not-allowed depending on surrounding Express middleware.
152
189
 
190
+ ### Public message DTOs (MSGR-03 migration)
191
+
192
+ `POST /new/:templateCd` returns `PublicMessageDto[]` for both fresh creation and
193
+ completed replay. The shipped Node host's list endpoint returns the same DTOs in
194
+ its existing `{ success: true, data }` envelope. Root named exports
195
+ `serializePublicMessage`, `PublicMessageDto`, `PublicMessageParty`, and
196
+ `PublicMessageSource` let custom HTTP adapters use this contract too:
197
+
198
+ ```typescript
199
+ import {
200
+ serializePublicMessage,
201
+ type MessageService,
202
+ type MessageUser,
203
+ type PublicMessageDto,
204
+ } from '@web-ts-toolkit/message-service';
205
+
206
+ export async function publicInbox(service: MessageService, user: MessageUser): Promise<PublicMessageDto[]> {
207
+ const messages = await service.listMessages({ user });
208
+ return messages.map(serializePublicMessage);
209
+ }
210
+ ```
211
+
212
+ The explicit allowlist is:
213
+
214
+ - `_id`, `templateCd`, `type`, `fromUser`, `toUser`, `toRoles`;
215
+ - `senderContent` and `receiverContent` (only `title`, `long`, `short`),
216
+ `documents` (attachment IDs), `payload`, `display`;
217
+ - `paymentCd` and `paymentSession` (intentional business payment reference);
218
+ - `actionState`, `actionCd`, `actionAttemptId`, `createdAt`, `updatedAt`;
219
+ - archives additionally expose `archivedBy`, `archivedAt`, `actionNotificationState`.
220
+
221
+ IDs are strings and dates are ISO strings. Unpopulated parties are IDs or `null`;
222
+ populated parties contain only `{ _id, displayName?, email? }`, with presentation
223
+ fields included only when they are strings. Missing/unsupported party references
224
+ become `null`; populated attachments and `archivedBy` become IDs. Populate
225
+ `email`/`displayName` only when appropriate for the response audience. The helper
226
+ expects stored message fields (including timestamps), accepts hydrated or plain
227
+ records, and performs no authorization itself.
228
+
229
+ Migration: clients must stop reading raw diagnostics, `actionOwnerToken`, claim
230
+ identity/timestamps/leases, notification-attempt timestamps, `clientRequest*`
231
+ bookkeeping, `__v`, or arbitrary schema/populated-user fields from HTTP messages.
232
+ They are no longer returned. Use `_id` for message identity and keep the submitted
233
+ request key client-side for replay. The intentional `actionAttemptId` contract
234
+ remains available for authorized outcome correlation, including existing 409/202
235
+ action responses; it is not a worker ownership credential. For archives, use
236
+ `archivedAt` to detect terminal state (`actionState` is the stored action snapshot).
237
+
238
+ Direct `createMessage`, find, and list service results remain hydrated Mongoose
239
+ records with internal diagnostics and lifecycle methods where applicable; the
240
+ serializer does not mutate them. Operational observers retain their raw errors.
241
+ `payload`, `display`, text content, and action-handler results are host-controlled
242
+ business data, **not automatically sanitized**. Custom fields require an explicit
243
+ host DTO extension after authorization rather than spreading stored documents.
244
+
245
+ ### Transaction cleanup diagnostics
246
+
247
+ Migration (MSGR-01): once MongoDB's `withTransaction()` resolves, session cleanup
248
+ failure no longer rejects creation or archival. Paid batches retain their payment
249
+ sessions and replay normally; committed actions continue to the documented
250
+ post-commit sender notification (`sent`, or `ActionNotificationPendingError` with
251
+ stored `failed` state). Direct `archive()` resolves successfully after commit.
252
+ If the transaction rejects, cleanup cannot replace its primary error. If a
253
+ transaction callback and its abort both reject, the callback error takes precedence.
254
+
255
+ Configure the root-exported `MessageTransactionCleanupFailureObserver` through
256
+ `MessageServiceOptions.onTransactionCleanupFailure` for service writes, and
257
+ `buildMessageSchema({ onTransactionCleanupFailure })` for direct document archival.
258
+ The root-exported `MessageTransactionCleanupFailureEvent` contains:
259
+
260
+ - `operation`: `createBatch`, `actionArchive`, or `directArchive`;
261
+ - `stage`: `endSession`, and `error`: the raw cleanup rejection;
262
+ - `outcome: 'committed'` after confirmed commit, or `outcome: 'failed'` with
263
+ `originalError` when the transaction rejected.
264
+
265
+ These are internal diagnostics, not HTTP response fields. The optional observer
266
+ is awaited once per failed package-owned session cleanup; synchronous throws and
267
+ asynchronous rejections are swallowed. Keep observers bounded (a never-settling
268
+ observer delays the call). Use them for logging/metrics, not payment compensation
269
+ or action retries. Without an observer, cleanup errors are suppressed.
270
+
271
+ Route construction forwards `onTransactionCleanupFailure` unchanged. With
272
+ `createMessageRoutes({ service })`, configure the observer on that service;
273
+ combining an injected service with this construction option is rejected at runtime
274
+ and by strict TypeScript. Schema configuration is independent: service options do
275
+ not reconfigure document methods. Direct archival uses the document's owning
276
+ connection; a session attached with `$session(session)` is borrowed and never
277
+ ended/reported by the package. Its owner remains responsible for cleanup; direct
278
+ archival still calls `withTransaction()` and does not join an already-open transaction.
279
+
280
+ `failed` means no commit was confirmed to this process, **not** proof of rollback.
281
+ Driver retries remain in effect. An unresolved commit acknowledgement/network
282
+ failure or process death can leave an ambiguous outcome; this hook supplies no
283
+ commit reconciliation or payment crash recovery. Reconcile such outcomes with
284
+ MongoDB and the payment provider out of band before retrying external effects.
285
+
153
286
  ### `MessageService`
154
287
 
155
288
  Core service for creating messages, getting actions, and handling actions. Constructed by `createMessageRoutes`, or directly:
156
289
 
157
290
  ```typescript
158
- import { MessageService, TemplateRegistry } from '@web-ts-toolkit/message-service';
291
+ import { MessageService, NoopPaymentProvider, TemplateRegistry } from '@web-ts-toolkit/message-service';
159
292
 
160
293
  const registry = new TemplateRegistry();
161
294
  const service = new MessageService({
162
295
  getModel: mongoose.model.bind(mongoose),
163
- paymentProvider: stripe,
296
+ paymentProvider: new NoopPaymentProvider(),
164
297
  adminRoles: ['superadmin'],
165
298
  registry,
166
299
  });
@@ -168,16 +301,39 @@ const service = new MessageService({
168
301
 
169
302
  Methods:
170
303
 
171
- - `createMessage(params)` — create from template. Supports `clientRequestId` for requester-and-template-scoped idempotency.
172
- - `createNotification(params)` — create a generic (action-less) notification. Accepts `fromUser`, `toUser`, `toRoles`, `receiverContent`, `senderContent`, `documents`.
304
+ - `createMessage(params)` — create from template. Supports `clientRequestId` for requester-and-template-scoped idempotency. Returns `Array<IMessage | IMessageArchive>` (fresh creates resolve active documents; completed replays merge active + archive records, narrowed with `'archivedAt' in doc`). `user` (and `payerUser` when provided) must carry a valid principal id (non-empty string or `ObjectId`); invalid principals throw `InvalidMessageUserError` before any template, payment, or model effect in both branches.
305
+ - `createNotification(params)` — create a generic (action-less) notification. Accepts raw `UserId` values (`fromUser`, `toUser`), `toRoles`, `receiverContent`/`senderContent` (`{ title, long, short? }`), `documents`. Returns `IMessage`. Trusted host-level operation: performs no principal validation.
173
306
  - `listMessages({ user, limit?, skip?, populate? })` — list messages visible to a user.
174
307
  - `countMessages(user)` — count messages visible to a user.
175
- - `findMessage(id, { populate?, select? })` — find a message by id (active or archive).
176
- - `findMessageOrThrow(id, { populate?, select? })` — same as `findMessage`, but throws `MessageNotFoundError`.
177
- - `getActions(id, usertype, { permissions?, isAdmin?, populate? })` — get available actions.
308
+ - `findMessage(id, { populate?, select? })` — find a message by id (active or archive). Trusted host-level operation: performs no principal validation.
309
+ - `findMessageOrThrow(id, { populate?, select? })` — same as `findMessage`, but throws `MessageNotFoundError`. Trusted host-level operation.
310
+ - `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.
178
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.
179
312
  - `buildVisibilityFilter(user)` — get the Mongoose filter for messages visible to a user.
180
313
 
314
+ Action-read migration: omit `getActions`' deprecated `message` option. It is
315
+ accepted for source compatibility but ignored, even when its ID matches. Every
316
+ listing reads the requested ID (active, then archive) through the service's
317
+ configured model resolver/connection. Missing records return `null`. The trusted
318
+ `findMessage` APIs still return hydrated snapshots without authorization; do not
319
+ use a cached result as proof of current access.
320
+
321
+ Relationships are checked against stored `fromUser`, `toUser`, and `toRoles`
322
+ before optional `populate`. Eligible active conditions receive that message with
323
+ the requested presentation population applied; admin/archive views skip both
324
+ population and conditions. Mutation conditions must also support unpopulated
325
+ references because `handleAction` rechecks the atomic claim without population.
326
+ Relationship methods accept string/ObjectId identities and populated `_id`s;
327
+ null, deleted, or projected-away populated references never match an identity.
328
+ An explicit recipient role can still grant access independently. These methods
329
+ do not check host account liveness: a valid authenticated principal can match a
330
+ canonical stored ID even if presentation population finds no user. Hosts own
331
+ account revocation and must clear stored relationships when that is their policy.
332
+
333
+ Listings and archived outcomes reflect their database read snapshot, not a
334
+ reservation or a promise of future access. Later writes may change availability;
335
+ active mutations retain their atomic claim-bound authorization and fencing.
336
+
181
337
  Pagination contract: `defaultListLimit` and `maxListLimit` must be finite integers, `maxListLimit` must be at least 1, and the default must not exceed the max. Request `limit` and `skip` values must also be finite integers; fractional, `NaN`, and infinite values are rejected. Request `limit <= 0` is normalized to 1, high limits are clamped to `maxListLimit`, and negative `skip` is normalized to 0 so invalid input never turns into an unbounded MongoDB query. Results are ordered by `{ createdAt: -1, _id: -1 }` for deterministic offset pages when timestamps tie.
182
338
 
183
339
  `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.
@@ -187,11 +343,13 @@ Pagination contract: `defaultListLimit` and `maxListLimit` must be finite intege
187
343
  In-memory registry for trusted message templates.
188
344
 
189
345
  ```typescript
346
+ import { TemplateRegistry, type RegisteredMessageTemplate } from '@web-ts-toolkit/message-service';
347
+
190
348
  const registry = new TemplateRegistry();
191
349
  registry.register(template);
192
- registry.find('template-cd');
350
+ const found: RegisteredMessageTemplate | undefined = registry.find('template-cd');
193
351
  registry.has('template-cd');
194
- registry.getAll();
352
+ const all: RegisteredMessageTemplate[] = registry.getAll();
195
353
  registry.unregister('template-cd');
196
354
  registry.clear();
197
355
  ```
@@ -200,7 +358,7 @@ registry.clear();
200
358
 
201
359
  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.
202
360
 
203
- Supported package-root exports are the service (`MessageService`), route factory (`createMessageRoutes`), schema factories and model-name constants, template helpers (`TemplateRegistry`, `defaultRegistry`, `includesAction`, `interpolateTemplate`, `filterActions`, `isActionAllowed`), no-op providers, provider/template/message types including `UserId`, and the typed errors listed below. Deep `dist/*` or `src/*` imports are not part of the public contract.
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.
204
362
 
205
363
  `includesAction(templateCd, actionCd, registry)` requires the registry explicitly so archive behavior never falls back to unrelated global state.
206
364
 
@@ -208,19 +366,27 @@ Templates are trusted application code, not caller-supplied configuration. `prep
208
366
 
209
367
  `register()` validates action metadata deterministically. A template cannot register duplicate `actionCd` values, an action unavailable to both sender and receiver, or multiple `isDefault` actions for the same usertype. Registering the same `templateCd` again still replaces the prior template after the new definition validates.
210
368
 
211
- The registry stores a frozen shallow snapshot of authorization/action-critical structure: content objects, `uiTemplate` objects, the action array, action objects, action confirmations, and action payload objects. Function fields keep their original identity, but mutating the object originally passed to `register()` or a template returned by `find()`/`getAll()` cannot accidentally change registered action authorization, handlers, or rendered content. Register a replacement template when behavior should change.
369
+ The registry stores a frozen shallow snapshot of authorization/action-critical structure: content objects, `uiTemplate` objects, the action array, action objects, action confirmations, and action payload objects. `find()`/`getAll()` return readonly `RegisteredMessageTemplate` views — mutating them throws at runtime (strict TypeScript also rejects the write). Function fields keep their original identity, but mutating the object originally passed to `register()` cannot accidentally change registered action authorization, handlers, or rendered content. Register a replacement template when behavior should change.
212
370
 
213
371
  Template interpolation uses Handlebars with `noEscape: true`. Rendered strings are plain text values, not sanitized HTML. If a frontend, email adapter, or server-rendered view inserts returned content into HTML, that renderer must HTML-escape the strings at the output boundary. The template engine intentionally does not sanitize markup because templates are trusted code and messages may be rendered in non-HTML contexts.
214
372
 
215
373
  Compiled template strings are cached process-locally for a finite static template set. Do not generate unbounded per-request template strings; if your application needs user-defined or dynamic templates, put a bounded cache and review workflow at that application boundary before passing templates to this package.
216
374
 
375
+ Rendering/action separation: message content (`senderContent`/`receiverContent`) is rendered at creation time from `PrepareResult.templateData` via `interpolateMessageContent`, without evaluating action `condition` predicates. Action availability is evaluated only when actions are actually requested/executed (`getActions` for eligible active messages via `filterActions`, `handleAction` via `isActionAllowed`) against the persisted message, and action `name`/confirmation strings compile against persisted `message.payload` — not `templateData`. The two data sources are deliberately distinct (no unified interpolation-data feature); missing values in either source render as empty strings. Read-only/admin (`isAdmin`) and archived `getActions` views return the resolved `uiTemplate` with an empty action list and never invoke conditions. The public combined helper `interpolateTemplate` is preserved (content + filtered actions) with unchanged `noEscape: true` plain-text semantics.
376
+
217
377
  ### Action lifecycle notes
218
378
 
219
- - Active messages start with `actionState: 'active'`. `handleAction()` validates the selected template action, permission, sender/receiver, and action condition at the service boundary before running template code.
379
+ - Active messages start with `actionState: 'active'`. `handleAction()` authorizes and selects the handler against the persisted document returned by the atomic claim — not the caller-supplied copy — covering `templateCd`, `toUser`/`fromUser`, `toRoles`, and condition payload data. A stale copy authorized before a stored change cannot execute. If a **fresh attempt** is denied before its handler runs, fenced release restores `active` and clears its action code, attempt ID, owner token, claimant, claim time, lease, and failure diagnostic to `null`. A legitimate caller can then choose any currently allowed action, including a different code in a replacement template, with a new attempt ID. A denied **retry or takeover** instead becomes `retryable`, retaining the same attempt ID and action restriction because earlier execution may have had effects. If ownership changed before either release, the replacement is untouched and the denied caller still receives its authorization error. Supported update protocol: the guarantee covers state as of the atomic claim; hosts must not mutate action-relevant fields concurrently with in-flight actions outside a coordinated protocol, and a fresh read alone is not a substitute for the claim-bound check.
380
+ - Migration: a fresh post-claim denial now leaves the message `active`, without a persisted denial diagnostic or reserved action choice, rather than `retryable`. Handle the authorization error and reload current message state before choosing a valid action. Existing retryable/expired attempts remain conservative same-action retries; this change does not automatically reset older denied attempts whose execution history is unknown.
381
+ - Archived outcomes re-read the archive by the supplied record's ID on its owning connection, retaining resolver/source-connection consistency checks. They require a sender/receiver relationship with that stored record before any current attempt ID or notification state is disclosed. Unrelated callers receive `ActionNotAllowedError` (403 on HTTP) with no attempt IDs on both direct and HTTP paths, including the claim-fallback path when the active copy is gone. Authorized sender/receiver retries remain available even when the template has been unregistered. Migration: supplied archive fields no longer determine the outcome; deleted/missing archives throw `MessageNotFoundError` rather than replaying cached notification state.
220
382
  - The service claims an action with an atomic conditional `findOneAndUpdate()`. Only an unclaimed active message, a retryable same-action attempt, or an expired same-action processing lease can move to `actionState: 'processing'`; competing same or different actions receive `ActionConflictError` while a live claim exists.
221
383
  - 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.
222
384
  - 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
+ - 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`.
387
+ - 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`.
223
388
  - 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
+ - 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.
224
390
  - When an action commits, the archive stores `actionAttemptId` and `actionNotificationState`. Actions without `senderNotification` store `none`. Actions with sender notification store `pending` in the archive transaction, then update to `sent` after post-commit delivery succeeds.
225
391
  - 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.
226
392
  - 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'`.
@@ -258,11 +424,12 @@ When you use `clientRequestId`, register all three models. `MessageRequest` stor
258
424
  - At the `MessageService` boundary, `clientRequestId` must be a string. It is trimmed before use, must remain non-empty after trimming, and must be at most 128 characters. Case is preserved, so `Request-1` and `request-1` are different idempotency keys.
259
425
  - The idempotency scope is exactly the trimmed `clientRequestId`, the requester identity (`String(user._id)`), and `templateCd`. A reused ID from another user or another template starts its own operation and never replays the first scope's messages.
260
426
  - The winning request reserves the scoped idempotency key before template preparation or payment-session creation runs.
261
- - Payment sessions created for messages that fail before persistence, or for idempotent batches whose transaction does not commit, are expired through `PaymentProvider.expireSession()`. A completed same-scope retry replays the committed message batch and does not create another payment session.
262
- - Reservations move through explicit `pending`, `completed`, and `failed` states. A completed replay is returned only when the completed reservation's `itemCount` exactly matches the persisted distinct `clientRequestItemIndex` values. Completed zero-item results are replayed as `[]`.
427
+ - Payment sessions created for messages that fail before persistence, or for idempotent batches whose transaction does not commit, are expired through `PaymentProvider.expireSession()`. Every newly created session tracked from preparation through the commit attempt is attempted — including earlier batch items when a later item's provider/render step fails, and sessions left uncommitted by model resolution, `startSession()`, persistence, or rollback failures. Every known session is attempted even when an earlier expiration or the `onPaymentCompensationFailure` observer fails; a lone session throws `PaymentSessionCompensationError`, multi-session batches throw `PaymentSessionCompensationAggregateError` (a subclass carrying every per-session failure in `failures` with the triggering error as `originalError`). Committed sessions are never expired because post-commit housekeeping failed, and non-idempotent sequential creation keeps per-item semantics (only the failing item is compensated; already-committed items are retained). Ambiguous provider/commit outcomes the process cannot observe must be reconciled with the provider out of band. A completed same-scope retry replays the committed message batch and does not create another payment session.
428
+ - Reservations move through explicit `pending`, `completed`, and `failed` states. A completed replay is returned only when the completed reservation's `itemCount` exactly matches the persisted distinct `clientRequestItemIndex` values merged across the active and archive collections (`Array<IMessage | IMessageArchive>`, sorted by item index). Action archival never breaks replay and never reruns preparation/payment: some/all archived batches replay from current records, with archive entries returned as persisted `IMessageArchive` (no fabricated active methods; narrow with `'archivedAt' in doc`). A commit landing between the reservation read and the message reads is reconciled (reservation re-read; one bounded collection re-read) rather than reported as corruption. Completed zero-item results are replayed as `[]`.
263
429
  - Losing requests in the same scope wait for the winning request to finish. If the reservation remains live beyond `clientRequestWaitMs`, `ClientRequestPendingError` is thrown and the caller should retry later. If the pending lease expires after `clientRequestLeaseMs`, exactly one retry can atomically take over the reservation.
430
+ - Timing configuration is validated at construction and throws `InvalidMessageServiceOptionError` for out-of-range values: `clientRequestLeaseMs` and `clientRequestPollMs` must be finite safe integers in `[1, 2147483647]` ms (positive lease/poll); `clientRequestWaitMs` must be in `[0, 2147483647]` ms (nonnegative wait, `0` is a supported immediate check that raises `ClientRequestPendingError` without sleeping when the first attempt cannot acquire or replay). `NaN`, infinite, fractional, negative (and zero lease/poll), and timer-overflow values above `2^31 - 1` (Node's maximum `setTimeout` delay, exported as `MAX_MESSAGE_SERVICE_TIMEOUT_MS`) are rejected. The duplicate-wait deadline bounds polling only — it never cancels a hung database/provider operation; each reservation/message read is awaited to completion, then the deadline decides whether to poll again or throw pending. Tests use injected `clientRequestDelay`/`clientRequestNow` hooks so boundary behavior is deterministic without long sleeps; production defaults use `setTimeout`/`Date.now`, with persisted `leaseExpiresAt` wall-clock timestamps (`new Date(now())`) and the stale-takeover filter compared against the same clock.
264
431
  - Multi-item idempotent batches are persisted in one MongoDB transaction together with the transition to `completed`. Production deployments that use `clientRequestId` therefore need MongoDB replica set or sharded-cluster transaction support. Standalone MongoDB servers fail with `MessageTransactionRequiredError` instead of returning partially persisted batches.
265
- - If creation fails, the reservation is recorded as `failed` and later retries raise `ClientRequestFailedError`. If persisted state is corrupt, such as a completed reservation missing an expected item index, the service raises `ClientRequestInconsistentStateError` rather than returning a partial array.
432
+ - If creation fails, the reservation is recorded as `failed` (raw diagnostic in `failureMessage`) and later retries raise `ClientRequestFailedError`. The direct-service error carries a stable public message (`clientRequestId "…" previously failed; retry with a new clientRequestId`) with the recorded diagnostic retained on `error.failureReason`/`cause`; the HTTP route returns 409 with that stable message only, never the recorded cause text. If persisted state is corrupt, such as a completed reservation missing an expected item index across both collections after reconciliation, the service raises `ClientRequestInconsistentStateError` rather than returning a partial array. Retain reservations and archive documents for the idempotency-replay window: archive TTL/deletion turns later replays into `ClientRequestInconsistentStateError`, and cross-owner/template IDs remain isolated scopes. The archive declares the same scoped `clientRequest*` indexes as the active collection (including a unique scope+item-index index).
266
433
 
267
434
  Migration note: versions with scoped idempotency add `clientRequestOwnerId` to messages and reservations and replace the old global `clientRequestId` unique indexes with compound indexes on `{ clientRequestOwnerId, templateCd, clientRequestId }` plus `clientRequestItemIndex` for message items. Drop any obsolete deployment-created unique index on `clientRequestId` before building the new schema indexes, otherwise different users or templates can still collide globally.
268
435
 
@@ -278,12 +445,14 @@ import {
278
445
  ActionRetryableError,
279
446
  ActionNotificationPendingError,
280
447
  InvalidClientRequestIdError,
448
+ InvalidMessageServiceOptionError,
281
449
  InvalidPaginationValueError,
282
450
  ClientRequestPendingError,
283
451
  ClientRequestFailedError,
284
452
  ClientRequestInconsistentStateError,
285
453
  MessageTransactionRequiredError,
286
454
  PaymentSessionCompensationError,
455
+ PaymentSessionCompensationAggregateError,
287
456
  MessageArchivedError,
288
457
  InvalidMessageUserError,
289
458
  ActionTemplateMismatchError,
@@ -294,27 +463,29 @@ import {
294
463
 
295
464
  The package brands exported errors so `instanceof` works across mixed ESM/CommonJS imports in one process.
296
465
 
297
- | Error | Direct-service meaning | Route status |
298
- | ------------------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------- |
299
- | `MessageNotFoundError` | `findMessageOrThrow()` could not find an active or archived message | 404 |
300
- | `TemplateNotFoundError` | Requested `templateCd` is not registered | 404 |
301
- | `ActionNotFoundError` | Requested action is not present on the message template | 404 |
302
- | `ActionNotAllowedError` | User lacks sender/receiver, permission, or condition access | 403 |
303
- | `MessageArchivedError` | Mutation was requested for an archived message | 410 |
304
- | `InvalidMessageUserError` | Missing/invalid user identity at the service boundary | 401 |
305
- | `InvalidClientRequestIdError` | `clientRequestId` is not a valid bounded string | 400 |
306
- | `ActionConflictError` | Another live action attempt owns the message | 409 |
307
- | `ActionRetryableError` | Handler failed before archive commit; same action may retry with the persisted `actionAttemptId` | 409 |
308
- | `ActionNotificationPendingError` | Business action committed, but sender notification still needs reconciliation | 202 |
309
- | `ClientRequestPendingError` | Duplicate create is still live; retry after completion or lease expiry | 409 |
310
- | `ClientRequestFailedError` | The scoped create operation has a recorded terminal failure | 409 |
311
- | `ClientRequestInconsistentStateError` | Persisted reservation/message state is corrupt | propagated as a server error |
312
- | `MessageTransactionRequiredError` | MongoDB deployment cannot provide required idempotent batch transactions | propagated as a server error |
313
- | `InvalidPaginationValueError` | Invalid service construction or list pagination input | propagated as a server error unless handled by host code |
314
- | `ActionTemplateMismatchError` | Direct `handleAction()` template argument does not match the message | propagated as a server error unless handled by host code |
315
- | `MessageModelResolutionError` | Configured active/archive/request/user model cannot be resolved | propagated as a server error |
316
- | `PaymentSessionCompensationError` | Create failed and at least one uncommitted payment session could not be expired | propagated as a server error |
317
- | `TemplateRegistryValidationError` | Invalid trusted template metadata at registration time | not thrown by route handlers |
466
+ | Error | Direct-service meaning | Route status |
467
+ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------- |
468
+ | `MessageNotFoundError` | `findMessageOrThrow()` could not find an active or archived message | 404 |
469
+ | `TemplateNotFoundError` | Requested `templateCd` is not registered | 404 |
470
+ | `ActionNotFoundError` | Requested action is not present on the message template | 404 |
471
+ | `ActionNotAllowedError` | User lacks sender/receiver, permission, or condition access | 403 |
472
+ | `MessageArchivedError` | Mutation was requested for an archived message | 410 |
473
+ | `InvalidMessageUserError` | Missing/invalid user identity at the service boundary | 401 |
474
+ | `InvalidClientRequestIdError` | `clientRequestId` is not a valid bounded string | 400 |
475
+ | `ActionConflictError` | Another live action attempt owns the message | 409 |
476
+ | `ActionRetryableError` | Handler failed before archive commit; same action may retry with the persisted `actionAttemptId` | 409 |
477
+ | `ActionNotificationPendingError` | Business action committed, but sender notification still needs reconciliation | 202 |
478
+ | `ClientRequestPendingError` | Duplicate create is still live; retry after completion or lease expiry | 409 |
479
+ | `ClientRequestFailedError` | The scoped create operation has a recorded terminal failure; public message is stable (`…previously failed; retry with a new clientRequestId`) and never includes the recorded `failureMessage` | 409 with the stable message only |
480
+ | `ClientRequestInconsistentStateError` | Persisted reservation/message state is corrupt | propagated as a server error |
481
+ | `MessageTransactionRequiredError` | MongoDB deployment cannot provide required idempotent batch transactions | propagated as a server error |
482
+ | `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 |
484
+ | `ActionTemplateMismatchError` | Direct `handleAction()` template argument does not match the message | propagated as a server error unless handled by host code |
485
+ | `MessageModelResolutionError` | Configured active/archive/request/user model cannot be resolved | propagated as a server error |
486
+ | `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
+ | `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
+ | `TemplateRegistryValidationError` | Invalid trusted template metadata at registration time | not thrown by route handlers |
318
489
 
319
490
  Malformed route values and ObjectId cast failures are translated to 400 before service/model/template lookup. Direct callers should treat `ClientRequestPendingError` as retryable, `ClientRequestFailedError` as a stable recorded failure for that scope, and `ClientRequestInconsistentStateError`/`MessageTransactionRequiredError` as operational errors requiring investigation. Action handlers should treat `ActionConflictError` as a live in-progress action, `ActionRetryableError` as a same-action retry using the persisted attempt key, and `ActionNotificationPendingError` as a committed business action whose sender notification still needs host reconciliation. Inspect `PaymentSessionCompensationError` and the compensation hook event before retrying or reconciling with the provider.
320
491
 
@@ -341,10 +512,12 @@ Pass it to `buildMessageSchema({ emailNotifier: provider.sendNotification.bind(p
341
512
  ### Payment Provider
342
513
 
343
514
  ```typescript
344
- import { PaymentProvider } from '@web-ts-toolkit/message-service';
515
+ import { type PaymentProvider, type UserId } from '@web-ts-toolkit/message-service';
345
516
 
346
- async function createCheckoutSession(user: unknown, code: string, priceArgs: unknown): Promise<string> {
347
- void { user, code, priceArgs };
517
+ async function createCheckoutSession(user: UserId, code: string, priceArgs?: Record<string, unknown>): Promise<string> {
518
+ void user;
519
+ void code;
520
+ void priceArgs;
348
521
  return 'session_123';
349
522
  }
350
523
 
@@ -357,13 +530,13 @@ async function refundCheckoutSession(sessionId: string): Promise<void> {
357
530
  }
358
531
 
359
532
  class StripePaymentProvider implements PaymentProvider {
360
- async createSession(user, code, priceArgs) {
533
+ async createSession(user: UserId, code: string, priceArgs?: Record<string, unknown>) {
361
534
  return await createCheckoutSession(user, code, priceArgs);
362
535
  }
363
- async expireSession(sessionId) {
536
+ async expireSession(sessionId: string) {
364
537
  await expireCheckoutSession(sessionId);
365
538
  }
366
- async refundPayment(sessionId) {
539
+ async refundPayment(sessionId: string) {
367
540
  await refundCheckoutSession(sessionId);
368
541
  }
369
542
  }
@@ -371,7 +544,7 @@ class StripePaymentProvider implements PaymentProvider {
371
544
 
372
545
  Pass it to `MessageService({ paymentProvider })` or `createMessageRoutes({ paymentProvider })`.
373
546
 
374
- Payment providers must treat `expireSession(sessionId)` and `refundPayment(sessionId)` as idempotent. During message creation, the service creates payment sessions before MongoDB can commit the message. If the message write or idempotent batch transaction fails, the service expires every session it created for that uncommitted work. If expiration fails, the service calls `onPaymentCompensationFailure` with the session id, scope, original error, and expiration error, then throws `PaymentSessionCompensationError`; the idempotency reservation is recorded as failed according to the create state machine.
547
+ Payment providers must treat `expireSession(sessionId)` and `refundPayment(sessionId)` as idempotent. During message creation, the service creates payment sessions before MongoDB can commit the message. If any prepared item, model/session setup step, or the idempotent batch transaction fails before commit, the service expires every newly created uncommitted session it tracked from preparation through the commit attempt (including earlier batch items when a later item's provider/render step fails, and sessions stranded by model resolution, `startSession()`, persistence, or rollback failures). Every known session is attempted even when an earlier expiration or the observer fails: `onPaymentCompensationFailure` is called once per failed session with the session id, scope, original error, and expiration error, then a lone session throws `PaymentSessionCompensationError` while multi-session batches throw `PaymentSessionCompensationAggregateError` (carrying every failure in `failures`); the idempotency reservation is recorded as failed according to the create state machine. Committed sessions are never expired because post-commit housekeeping failed, and non-idempotent sequential creation keeps per-item semantics (only the failing item is compensated; already-committed items are retained). Provider methods stay bound to the provider instance. Process death before a returned session is recorded, or any ambiguous provider/commit outcome, must be reconciled with the provider out of band.
375
548
 
376
549
  ## License
377
550