@web-ts-toolkit/message-service 0.43.0 → 0.48.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 +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/index.d.ts CHANGED
@@ -2,6 +2,27 @@ import mongoose from 'mongoose';
2
2
  import JsonRouter from '@web-ts-toolkit/express-json-router';
3
3
  import { Request, Response, NextFunction } from 'express';
4
4
 
5
+ /** Diagnostics for a package-owned session whose `endSession()` rejected. */
6
+ type MessageTransactionCleanupFailureEvent = {
7
+ operation: 'createBatch' | 'actionArchive' | 'directArchive';
8
+ stage: 'endSession';
9
+ error: unknown;
10
+ } & ({
11
+ outcome: 'committed';
12
+ originalError?: never;
13
+ } | {
14
+ /** Transaction rejected; this does not certify rollback of an ambiguous commit. */
15
+ outcome: 'failed';
16
+ originalError: unknown;
17
+ });
18
+ /**
19
+ * Best-effort diagnostic observer, awaited after session cleanup rejects.
20
+ * Throws/rejections are swallowed to preserve the committed result or primary
21
+ * transaction error. Keep it bounded; it is not a retry/compensation hook.
22
+ * Caller-owned sessions are never ended or reported by the package.
23
+ */
24
+ type MessageTransactionCleanupFailureObserver = (event: MessageTransactionCleanupFailureEvent) => void | Promise<void>;
25
+
5
26
  interface PrepareContext {
6
27
  user: MessageUser;
7
28
  roles: string[];
@@ -87,6 +108,40 @@ interface MessageTemplate {
87
108
  prepareMessage: (ctx: PrepareContext) => Promise<PrepareResult | PrepareResult[] | null>;
88
109
  actions: MessageAction[];
89
110
  }
111
+ /**
112
+ * Readonly uiTemplate view matching the registry freeze depth: a plain string
113
+ * passes through, while object forms are shallow-frozen at registration.
114
+ */
115
+ type RegisteredUiTemplate = string | Readonly<{
116
+ sender?: string;
117
+ receiver?: string;
118
+ }>;
119
+ /**
120
+ * Readonly action view matching the registry freeze depth: the action object,
121
+ * its `confirmation`, and its `payload` are each shallow-frozen. Function
122
+ * fields (`condition`, `runHandler`, `senderNotification`) keep their original
123
+ * identity and remain callable.
124
+ */
125
+ type RegisteredMessageAction = Readonly<Omit<MessageAction, 'confirmation' | 'payload'>> & {
126
+ readonly confirmation?: Readonly<ActionConfirmation>;
127
+ readonly payload?: Readonly<Record<string, unknown>>;
128
+ };
129
+ /**
130
+ * Readonly template view matching the registry freeze depth: the top-level
131
+ * snapshot, `senderContent`/`receiverContent`, object `uiTemplate`s, the
132
+ * action array, and each action plus its `confirmation`/`payload` are frozen.
133
+ * Mutating a registered view throws at runtime; register a replacement
134
+ * template to change behavior.
135
+ *
136
+ * Registration inputs stay author-friendly and mutable (`MessageTemplate`);
137
+ * only registry outputs use this readonly view.
138
+ */
139
+ type RegisteredMessageTemplate = Readonly<Omit<MessageTemplate, 'senderContent' | 'receiverContent' | 'uiTemplate' | 'actions'>> & {
140
+ readonly senderContent: Readonly<MessageTemplate['senderContent']>;
141
+ readonly receiverContent: Readonly<MessageTemplate['receiverContent']>;
142
+ readonly uiTemplate: RegisteredUiTemplate;
143
+ readonly actions: readonly RegisteredMessageAction[];
144
+ };
90
145
  interface InterpolatedContent {
91
146
  title: string;
92
147
  long: string;
@@ -139,6 +194,9 @@ declare class TemplateRegistry {
139
194
  private templates;
140
195
  /**
141
196
  * Register a template. Overwrites if templateCd already exists.
197
+ *
198
+ * Takes an author-friendly mutable `MessageTemplate`; the registry stores a
199
+ * frozen shallow snapshot (see `RegisteredMessageTemplate`).
142
200
  */
143
201
  register(template: MessageTemplate): void;
144
202
  /**
@@ -147,8 +205,13 @@ declare class TemplateRegistry {
147
205
  registerAll(templates: MessageTemplate[]): void;
148
206
  /**
149
207
  * Find a template by its templateCd.
208
+ *
209
+ * Returns a readonly `RegisteredMessageTemplate` view matching the registry
210
+ * freeze depth (top-level, content objects, object uiTemplates, the action
211
+ * array, and each action plus its confirmation/payload are frozen).
212
+ * Mutating the result throws at runtime; register a replacement instead.
150
213
  */
151
- find(templateCd: string): MessageTemplate | undefined;
214
+ find(templateCd: string): RegisteredMessageTemplate | undefined;
152
215
  /**
153
216
  * Check if a template exists.
154
217
  */
@@ -159,8 +222,10 @@ declare class TemplateRegistry {
159
222
  getTemplateCodes(): string[];
160
223
  /**
161
224
  * Get all registered templates.
225
+ *
226
+ * Returns readonly `RegisteredMessageTemplate` views; see `find()`.
162
227
  */
163
- getAll(): MessageTemplate[];
228
+ getAll(): RegisteredMessageTemplate[];
164
229
  /**
165
230
  * Remove a template by templateCd.
166
231
  */
@@ -204,10 +269,30 @@ interface MessageUser {
204
269
  displayName?: string;
205
270
  }
206
271
  interface IMessageRelationshipMethods {
272
+ /** Match a string/ObjectId or populated party's `_id`; missing references never match. */
207
273
  isSender(user: MessageUser): boolean;
274
+ /** Match the recipient identity (including populated `_id`) or an explicit recipient role. */
208
275
  isReceiver(user: MessageUser): boolean;
209
276
  }
210
277
  interface IMessageMethods extends IMessageRelationshipMethods {
278
+ /**
279
+ * Trusted host-level archival primitive: atomically moves the active message
280
+ * into the archive collection on the document's owning connection.
281
+ *
282
+ * The host must authorize before calling (no sender/receiver, permission, or
283
+ * condition checks run here). Invalid `actionCd` throws
284
+ * `TemplateNotFoundError`/`ActionNotFoundError`; an invalid `archivedBy`
285
+ * throws `InvalidMessageUserError` — never a silent no-op success. A live
286
+ * in-flight service claim (`processing` without an expired lease) throws
287
+ * `ActionConflictError` and steals nothing. Repeat calls throw
288
+ * `MessageArchivedError` (archive copy exists) or `MessageNotFoundError`.
289
+ * Deployments without transaction support throw
290
+ * `MessageTransactionRequiredError` before writing anything.
291
+ * Confirmed commit resolves even if session cleanup rejects; configure
292
+ * `buildMessageSchema({ onTransactionCleanupFailure })` for diagnostics.
293
+ * Observer failures cannot change the outcome. An attached `$session()` is
294
+ * borrowed and never ended by this method; its caller owns cleanup.
295
+ */
211
296
  archive(actionCd: string, archivedBy: UserId, registry: TemplateRegistry): Promise<void>;
212
297
  }
213
298
  /**
@@ -237,6 +322,7 @@ interface IBaseMessage {
237
322
  actionState: ActionLifecycleState;
238
323
  actionCd: string | null;
239
324
  actionAttemptId: string | null;
325
+ actionOwnerToken: string | null;
240
326
  actionClaimedBy: string | null;
241
327
  actionClaimedAt: Date | null;
242
328
  actionLeaseExpiresAt: Date | null;
@@ -253,6 +339,7 @@ type IMessageArchive = mongoose.Document<unknown, MongooseQueryHelpers, IMessage
253
339
  archivedBy: UserId;
254
340
  archivedAt: Date;
255
341
  actionAttemptId: string | null;
342
+ actionOwnerToken: string | null;
256
343
  actionNotificationState: ActionNotificationState;
257
344
  actionNotificationError: string | null;
258
345
  actionNotificationAttemptedAt: Date | null;
@@ -322,7 +409,11 @@ interface MessageSchemaConfig {
322
409
  * send a best-effort email notification to the recipient. Pass `null` (the
323
410
  * default) to disable.
324
411
  *
325
- * When `null`, no pre-save hook is registered at all.
412
+ * When `null`, no save hooks are registered at all. When set, the schema
413
+ * registers a pre-save state capture plus a connection-local post-save
414
+ * hook: the post-save hook resolves user/archive models on the hydrated
415
+ * document's owning connection and skips session-bound writes (Mongoose
416
+ * save hooks run before the surrounding transaction commits).
326
417
  */
327
418
  emailNotifier?: EmailNotifier | null;
328
419
  /**
@@ -340,11 +431,15 @@ interface MessageSchemaConfig {
340
431
  emailNotificationExclusions?: string[];
341
432
  /**
342
433
  * Name of the Mongoose model that holds the recipient user records.
343
- * Defaults to `'User'`. The pre-save hook uses `mongoose.model(name)`
344
- * to look up the recipient's email address.
434
+ * Defaults to `'User'`. The post-save email hook resolves it on the
435
+ * message document's owning connection
436
+ * (`document.constructor.db.model(name)`) to look up the recipient's email
437
+ * address, so connection-local apps must register the user model on the
438
+ * same connection as the message models.
345
439
  *
346
- * The model MUST be registered with Mongoose before the first save —
347
- * `buildMessageSchema` does a sanity check to give a clear error if not.
440
+ * The model MUST be registered before the first save when `emailNotifier`
441
+ * is set — `buildMessageSchema` checks this eagerly to fail fast (pass
442
+ * `connection` when configuring connection-local schemas).
348
443
  */
349
444
  userModelName?: string;
350
445
  /**
@@ -358,12 +453,20 @@ interface MessageSchemaConfig {
358
453
  * Defaults to `'MessageArchive'`. Used by the `archive()` instance method.
359
454
  */
360
455
  archiveModelName?: string;
456
+ /**
457
+ * Best-effort diagnostics for `endSession()` rejection after direct `archive()`.
458
+ * Preserves committed success or the primary transaction error even when the
459
+ * observer throws/rejects. Awaited; keep it bounded. Borrowed document sessions
460
+ * remain caller-owned and are not ended/reported. Service writes use the
461
+ * separate `MessageServiceOptions.onTransactionCleanupFailure` observer.
462
+ */
463
+ onTransactionCleanupFailure?: MessageTransactionCleanupFailureObserver;
361
464
  }
362
465
  type MessageModel = mongoose.Model<IBaseMessage, object, IMessageMethods>;
363
466
  /**
364
467
  * Build a fresh Message schema with the given configuration.
365
468
  * Prefer this over the default `MessageSchema` export when you need
366
- * an email notifier, exclusions, or custom model names.
469
+ * an email notifier, exclusions, cleanup diagnostics, or custom model names.
367
470
  *
368
471
  * Note: when `emailNotifier` is set, the configured `userModelName` MUST
369
472
  * be registered with Mongoose before calling this function. The schema
@@ -371,7 +474,7 @@ type MessageModel = mongoose.Model<IBaseMessage, object, IMessageMethods>;
371
474
  */
372
475
  declare function buildMessageSchema(config?: MessageSchemaConfig): mongoose.Schema<IBaseMessage, MessageModel, IMessageMethods>;
373
476
  /**
374
- * Default Message schema with no email notifier and no pre-save hook.
477
+ * Default Message schema with no email notifier and no save hooks.
375
478
  * Provided for backwards compatibility and simple use cases.
376
479
  * Use `buildMessageSchema(config)` when you need custom behavior.
377
480
  */
@@ -546,6 +649,15 @@ declare const MessageSchema: mongoose.Schema<IBaseMessage, MessageModel, IMessag
546
649
  }, "id" | keyof IMessageMethods> & mongoose.HydratedDocumentOverrides<{
547
650
  id: string;
548
651
  } & IMessageMethods>> | undefined;
652
+ actionOwnerToken?: mongoose.SchemaDefinitionProperty<string | null, IBaseMessage, mongoose.Document<unknown, {}, IBaseMessage, {
653
+ id: string;
654
+ }, mongoose.DefaultSchemaOptions> & Omit<IBaseMessage & {
655
+ _id: mongoose.Types.ObjectId;
656
+ } & {
657
+ __v: number;
658
+ }, "id" | keyof IMessageMethods> & mongoose.HydratedDocumentOverrides<{
659
+ id: string;
660
+ } & IMessageMethods>> | undefined;
549
661
  actionClaimedBy?: mongoose.SchemaDefinitionProperty<string | null, IBaseMessage, mongoose.Document<unknown, {}, IBaseMessage, {
550
662
  id: string;
551
663
  }, mongoose.DefaultSchemaOptions> & Omit<IBaseMessage & {
@@ -945,6 +1057,23 @@ declare const MessageArchiveSchema: mongoose.Schema<MessageArchiveSchemaFields,
945
1057
  }, "id" | keyof IMessageRelationshipMethods> & mongoose.HydratedDocumentOverrides<{
946
1058
  id: string;
947
1059
  } & IMessageRelationshipMethods>> | undefined;
1060
+ actionOwnerToken?: mongoose.SchemaDefinitionProperty<string | null, MessageArchiveSchemaFields, mongoose.Document<unknown, {}, MessageArchiveSchemaFields, {
1061
+ id: string;
1062
+ }, mongoose.DefaultSchemaOptions> & Omit<IBaseMessage & {
1063
+ actionCd: string;
1064
+ archivedBy: unknown;
1065
+ archivedAt: Date;
1066
+ actionAttemptId: string | null;
1067
+ actionNotificationState: "none" | "pending" | "sent" | "failed";
1068
+ actionNotificationError: string | null;
1069
+ actionNotificationAttemptedAt: Date | null;
1070
+ } & {
1071
+ _id: mongoose.Types.ObjectId;
1072
+ } & {
1073
+ __v: number;
1074
+ }, "id" | keyof IMessageRelationshipMethods> & mongoose.HydratedDocumentOverrides<{
1075
+ id: string;
1076
+ } & IMessageRelationshipMethods>> | undefined;
948
1077
  actionClaimedBy?: mongoose.SchemaDefinitionProperty<string | null, MessageArchiveSchemaFields, mongoose.Document<unknown, {}, MessageArchiveSchemaFields, {
949
1078
  id: string;
950
1079
  }, mongoose.DefaultSchemaOptions> & Omit<IBaseMessage & {
@@ -1081,7 +1210,7 @@ declare const MessageArchiveSchema: mongoose.Schema<MessageArchiveSchemaFields,
1081
1210
  }, "id" | keyof IMessageRelationshipMethods> & mongoose.HydratedDocumentOverrides<{
1082
1211
  id: string;
1083
1212
  } & IMessageRelationshipMethods>> | undefined;
1084
- actionNotificationState?: mongoose.SchemaDefinitionProperty<"none" | "pending" | "sent" | "failed", MessageArchiveSchemaFields, mongoose.Document<unknown, {}, MessageArchiveSchemaFields, {
1213
+ actionNotificationState?: mongoose.SchemaDefinitionProperty<"failed" | "none" | "pending" | "sent", MessageArchiveSchemaFields, mongoose.Document<unknown, {}, MessageArchiveSchemaFields, {
1085
1214
  id: string;
1086
1215
  }, mongoose.DefaultSchemaOptions> & Omit<IBaseMessage & {
1087
1216
  actionCd: string;
@@ -1185,7 +1314,7 @@ declare const MessageRequestSchema: mongoose.Schema<MessageRequestSchemaFields,
1185
1314
  }, "id"> & mongoose.HydratedDocumentOverrides<{
1186
1315
  id: string;
1187
1316
  }>> | undefined;
1188
- state?: mongoose.SchemaDefinitionProperty<"pending" | "failed" | "completed", MessageRequestSchemaFields, mongoose.Document<unknown, {}, MessageRequestSchemaFields, {
1317
+ state?: mongoose.SchemaDefinitionProperty<"failed" | "pending" | "completed", MessageRequestSchemaFields, mongoose.Document<unknown, {}, MessageRequestSchemaFields, {
1189
1318
  id: string;
1190
1319
  }, mongoose.DefaultSchemaOptions> & Omit<MessageRequestSchemaFields & {
1191
1320
  _id: mongoose.Types.ObjectId;
@@ -1268,15 +1397,68 @@ declare const MessageRequestSchema: mongoose.Schema<MessageRequestSchemaFields,
1268
1397
  }>> | undefined;
1269
1398
  }, MessageRequestSchemaFields>;
1270
1399
 
1400
+ /**
1401
+ * Single explicit permission predicate shared by every permission boundary:
1402
+ * UI filtering (`filterActions`), action execution (`isActionAllowed`), and
1403
+ * the route's admin read-only lookup.
1404
+ *
1405
+ * A grant requires an **own** property of the permissions map whose value is
1406
+ * strictly `true`. Inherited properties (e.g. `constructor`, `toString`,
1407
+ * or custom keys from a prototype chain — including the default
1408
+ * `Object.prototype`) are denied, as are truthy non-booleans (`1`, `"true"`,
1409
+ * `{}`, ...), `false`, and absent keys.
1410
+ *
1411
+ * Normal object literals and null-prototype maps (`Object.create(null)`,
1412
+ * including JSON-parsed or `Object.assign`-built maps with own properties)
1413
+ * both work: the check uses `Object.prototype.hasOwnProperty.call`, so it is
1414
+ * safe for maps containing a `__proto__` key and for maps without a
1415
+ * prototype. Non-object permission containers (`null`, `undefined`,
1416
+ * primitives, arrays) never grant.
1417
+ *
1418
+ * Tightening note for custom `getPermissions` extractors: extractors that
1419
+ * previously relied on inherited or truthy non-boolean values must now return
1420
+ * own properties set to boolean `true`; anything else is treated as denied.
1421
+ */
1422
+ declare function hasExplicitPermissionGrant(permissions: Record<string, boolean> | undefined | null, key: string): boolean;
1423
+ /**
1424
+ * Render sender/receiver content only, without evaluating action predicates.
1425
+ *
1426
+ * Creation uses this path with `PrepareResult.templateData`: content is a
1427
+ * function of preparation output only. Action `condition` predicates are never
1428
+ * invoked here, so a condition that accesses persisted message fields (e.g.
1429
+ * `message.payload.ready`) cannot fail creation. Action labels/confirmations
1430
+ * are a separate concern rendered from persisted `message.payload` at listing
1431
+ * time (see `filterActions`); there is deliberately no unified
1432
+ * interpolation-data feature.
1433
+ *
1434
+ * Escaping/trusted-template semantics match `interpolateTemplate`: Handlebars
1435
+ * with `noEscape: true`, plain-text output, trusted template strings only.
1436
+ */
1437
+ declare function interpolateMessageContent(template: Pick<MessageTemplate | RegisteredMessageTemplate, 'senderContent' | 'receiverContent'>, data: Record<string, unknown>): Pick<InterpolationResult, 'senderContent' | 'receiverContent'>;
1438
+ /**
1439
+ * Resolve the uiTemplate for a usertype without evaluating actions.
1440
+ * Read-only/admin/archive listings use this plus an empty action list so
1441
+ * `condition` functions are never invoked merely to discard their results.
1442
+ *
1443
+ * Accepts both mutable author inputs and frozen registry views.
1444
+ */
1445
+ declare function resolveUiTemplate(uiTemplate: UiTemplate | RegisteredMessageTemplate['uiTemplate'], usertype: Usertype): string;
1271
1446
  /**
1272
1447
  * Apply the standard set of action filters (usertype match, permission, condition).
1273
1448
  * This is the single source of truth used by both the template engine (for UI)
1274
1449
  * and the server-side action handler (for authorization).
1275
1450
  *
1451
+ * Permission gating uses {@link hasExplicitPermissionGrant}: only an own
1452
+ * property strictly equal to `true` grants access.
1453
+ *
1276
1454
  * The `name` and confirmation `title`/`message`/`notesLabel` strings are
1277
- * compiled against the same `data` passed to `interpolateTemplate`.
1455
+ * compiled against the persisted `message.payload` snapshot passed as `data`
1456
+ * by `getActions` (via `interpolateTemplate`). This is intentionally different
1457
+ * from message content, which is rendered at creation time from
1458
+ * `PrepareResult.templateData`. Missing values in either source render as
1459
+ * empty strings via Handlebars.
1278
1460
  */
1279
- declare function filterActions(actions: MessageAction[], usertype: Usertype, options?: {
1461
+ declare function filterActions(actions: readonly (MessageAction | RegisteredMessageAction)[], usertype: Usertype, options?: {
1280
1462
  permissions?: Record<string, boolean>;
1281
1463
  message?: Record<string, unknown>;
1282
1464
  data?: Record<string, unknown>;
@@ -1285,15 +1467,26 @@ declare function filterActions(actions: MessageAction[], usertype: Usertype, opt
1285
1467
  * Check if the given user is allowed to execute the action on the given message.
1286
1468
  * Returns true if the action is permitted, false otherwise.
1287
1469
  * Mirrors the `filterActions` logic exactly so the UI and the server agree.
1470
+ *
1471
+ * Permission gating uses {@link hasExplicitPermissionGrant}: only an own
1472
+ * property strictly equal to `true` grants access.
1288
1473
  */
1289
- declare function isActionAllowed(action: MessageAction, user: MessageUser, message: IMessage | IMessageArchive, options?: {
1474
+ declare function isActionAllowed(action: MessageAction | RegisteredMessageAction, user: MessageUser, message: IMessage | IMessageArchive, options?: {
1290
1475
  permissions?: Record<string, boolean>;
1291
1476
  }): boolean;
1292
1477
  /**
1293
1478
  * Interpolate a template's sender/receiver content and filter actions
1294
1479
  * for the given usertype (sender or receiver).
1480
+ *
1481
+ * Preserved public combined helper: content is rendered from `data`
1482
+ * (callers pass `templateData` at creation via `interpolateMessageContent`,
1483
+ * or persisted `message.payload` at listing time), while action predicates
1484
+ * receive the documented `message` context and action labels/confirmations
1485
+ * compile against `data`. Creation and read-only/admin/archive paths must use
1486
+ * `interpolateMessageContent`/`resolveUiTemplate` directly so conditions are
1487
+ * not evaluated unnecessarily.
1295
1488
  */
1296
- declare function interpolateTemplate(template: MessageTemplate, data: Record<string, unknown>, usertype: Usertype, options?: {
1489
+ declare function interpolateTemplate(template: MessageTemplate | RegisteredMessageTemplate, data: Record<string, unknown>, usertype: Usertype, options?: {
1297
1490
  permissions?: Record<string, boolean>;
1298
1491
  message?: Record<string, unknown>;
1299
1492
  }): InterpolationResult;
@@ -1319,13 +1512,30 @@ declare class NoopEmailProvider implements EmailProvider {
1319
1512
  * Host apps implement this to handle payment sessions for messages
1320
1513
  * that require payment (e.g. Stripe, Adyen, Paddle).
1321
1514
  *
1322
- * Message creation may create external sessions before MongoDB commit. If the
1323
- * message write or idempotent batch transaction fails, `MessageService` calls
1324
- * `expireSession()` for every newly created uncommitted session. Providers must
1325
- * make expiration idempotent because callers may retry cleanup after ambiguous
1326
- * network or provider failures. If expiration itself fails, message creation
1327
- * fails with `PaymentSessionCompensationError` and the optional service hook is
1328
- * invoked for observability.
1515
+ * Message creation may create external sessions before MongoDB commit. If any
1516
+ * prepared item, model/session setup step, or idempotent batch transaction
1517
+ * fails before commit, `MessageService` calls `expireSession()` for every
1518
+ * newly created uncommitted session tracked from preparation through the
1519
+ * commit attempt — including sessions from earlier batch items when a later
1520
+ * item's provider/render step fails, and sessions left uncommitted by model
1521
+ * resolution, `startSession()`, persistence, or rollback failures. Every
1522
+ * known session is attempted even when an earlier expiration or the
1523
+ * `onPaymentCompensationFailure` observer fails; multi-session batches then
1524
+ * throw `PaymentSessionCompensationAggregateError` (a
1525
+ * `PaymentSessionCompensationError` carrying every per-session failure in
1526
+ * `failures` with the triggering error as `originalError`), while a lone
1527
+ * session keeps the single-session `PaymentSessionCompensationError` shape.
1528
+ * Providers must make expiration idempotent because callers may retry cleanup
1529
+ * after ambiguous network or provider failures. Committed sessions are never
1530
+ * expired because post-commit housekeeping failed; a completed same-scope
1531
+ * retry replays the committed batch without creating replacement sessions.
1532
+ * Non-idempotent sequential creation keeps per-item semantics: only the
1533
+ * failing item is compensated, already-committed items are retained.
1534
+ * Process death before a returned session is recorded, or any ambiguous
1535
+ * provider/commit outcome this process cannot observe, must be reconciled
1536
+ * with the provider out of band. If expiration itself fails, message creation
1537
+ * fails with the compensation error and the optional service hook is invoked
1538
+ * once per failed session for observability.
1329
1539
  *
1330
1540
  * `priceArgs` is intentionally a free-form `Record<string, unknown>` so
1331
1541
  * providers can accept their own metadata (currency, line items, etc.).
@@ -1360,67 +1570,8 @@ declare class NoopPaymentProvider implements PaymentProvider {
1360
1570
  refundPayment(_sessionId: string): Promise<void>;
1361
1571
  }
1362
1572
 
1363
- interface MessageServiceOptions {
1364
- getModel?: (name: string) => mongoose.Model<unknown>;
1365
- connection?: mongoose.Connection;
1366
- modelNames?: Partial<MessageServiceModelNames>;
1367
- paymentProvider?: PaymentProvider | null;
1368
- /**
1369
- * Called when the service cannot expire an external payment session that was
1370
- * created for a message batch that did not commit.
1371
- */
1372
- onPaymentCompensationFailure?: (event: PaymentCompensationFailureEvent) => void | Promise<void>;
1373
- adminRoles?: string[];
1374
- registry?: TemplateRegistry;
1375
- /**
1376
- * Maximum number of messages returned by `listMessages` when no explicit
1377
- * limit is provided. Defaults to 50.
1378
- */
1379
- defaultListLimit?: number;
1380
- /**
1381
- * Hard upper bound for `listMessages` limit to prevent abuse. Defaults to 100.
1382
- */
1383
- maxListLimit?: number;
1384
- /**
1385
- * How long a pending idempotent create reservation lease remains live before
1386
- * another caller may atomically take it over. Defaults to 30000 ms.
1387
- */
1388
- clientRequestLeaseMs?: number;
1389
- /**
1390
- * Maximum time a duplicate idempotent create waits for completion or stale
1391
- * lease takeover before raising `ClientRequestPendingError`. Defaults to 5000 ms.
1392
- */
1393
- clientRequestWaitMs?: number;
1394
- /** Poll interval while waiting for an idempotent create outcome. Defaults to 200 ms. */
1395
- clientRequestPollMs?: number;
1396
- /** Test hook for deterministic waiting; production uses `setTimeout`. */
1397
- clientRequestDelay?: (ms: number) => Promise<void>;
1398
- }
1399
- interface MessageServiceModelNames {
1400
- active: string;
1401
- archive: string;
1402
- request: string;
1403
- user: string;
1404
- }
1405
- interface PaymentCompensationFailureEvent {
1406
- operation: 'expire';
1407
- sessionId: string;
1408
- error: unknown;
1409
- originalError: unknown;
1410
- clientRequestId?: string;
1411
- clientRequestOwnerId?: string;
1412
- templateCd?: string;
1413
- }
1414
- type HydratedModelSource = {
1415
- constructor: unknown;
1416
- };
1417
- type MessageModelRole = keyof MessageServiceModelNames;
1418
- /**
1419
- * The `templateCd` used for generic notifications created via
1420
- * `createNotification`. These messages have no actions, so
1421
- * `getActions` returns an empty list for them.
1422
- */
1423
- declare const GENERIC_NOTIFICATION_TEMPLATE_CD = "__generic-notification__";
1573
+ /** Model roles the service resolves on a Mongoose connection or resolver. */
1574
+ type MessageModelRole = 'active' | 'archive' | 'request' | 'user';
1424
1575
  declare class MessageNotFoundError extends Error {
1425
1576
  static [Symbol.hasInstance](value: unknown): boolean;
1426
1577
  constructor(messageId: string);
@@ -1472,12 +1623,28 @@ declare class InvalidPaginationValueError extends Error {
1472
1623
  static [Symbol.hasInstance](value: unknown): boolean;
1473
1624
  constructor(message: string);
1474
1625
  }
1626
+ declare class InvalidMessageServiceOptionError extends Error {
1627
+ static [Symbol.hasInstance](value: unknown): boolean;
1628
+ constructor(message: string);
1629
+ }
1475
1630
  declare class ClientRequestPendingError extends Error {
1476
1631
  static [Symbol.hasInstance](value: unknown): boolean;
1477
1632
  constructor(clientRequestId: string);
1478
1633
  }
1479
1634
  declare class ClientRequestFailedError extends Error {
1480
1635
  static [Symbol.hasInstance](value: unknown): boolean;
1636
+ /**
1637
+ * Scoped request id for this recorded failure. Safe to expose: it is the
1638
+ * caller-supplied id within the caller's own owner/template scope.
1639
+ */
1640
+ clientRequestId: string;
1641
+ /**
1642
+ * Recorded terminal-failure diagnostic (the `failureMessage` persisted on
1643
+ * the request record). Retained for internal observers/direct-service
1644
+ * callers; never serialized into HTTP responses. See `route-factory`
1645
+ * `mapServiceError`, which returns a stable public message without it.
1646
+ */
1647
+ failureReason: string | null;
1481
1648
  constructor(clientRequestId: string, reason?: string | null);
1482
1649
  }
1483
1650
  declare class ClientRequestInconsistentStateError extends Error {
@@ -1504,6 +1671,156 @@ declare class PaymentSessionCompensationError extends Error {
1504
1671
  hookError?: unknown;
1505
1672
  constructor(sessionId: string, operation: 'expire', compensationError: unknown, originalError: unknown, hookError?: unknown);
1506
1673
  }
1674
+ /**
1675
+ * One failed `expireSession` (or compensation-hook) attempt within a batch.
1676
+ */
1677
+ interface PaymentSessionCompensationFailure {
1678
+ sessionId: string;
1679
+ compensationError: unknown;
1680
+ hookError?: unknown;
1681
+ }
1682
+ /**
1683
+ * Thrown when compensating a multi-session batch leaves one or more
1684
+ * uncommitted sessions unexpired.
1685
+ *
1686
+ * Extends `PaymentSessionCompensationError` so existing single-session
1687
+ * `instanceof` checks keep working. `sessionId`/`compensationError`/
1688
+ * `hookError` mirror the first failure for backwards compatibility; the full
1689
+ * per-session list lives in `failures` and `originalError` is the failure
1690
+ * that triggered compensation. Every known session is attempted even when an
1691
+ * earlier cleanup fails, and the hook is invoked once per failed session.
1692
+ */
1693
+ declare class PaymentSessionCompensationAggregateError extends PaymentSessionCompensationError {
1694
+ static [Symbol.hasInstance](value: unknown): boolean;
1695
+ failures: PaymentSessionCompensationFailure[];
1696
+ constructor(failures: PaymentSessionCompensationFailure[], originalError: unknown);
1697
+ }
1698
+
1699
+ interface MessageServiceOptions {
1700
+ getModel?: (name: string) => mongoose.Model<unknown>;
1701
+ connection?: mongoose.Connection;
1702
+ modelNames?: Partial<MessageServiceModelNames>;
1703
+ paymentProvider?: PaymentProvider | null;
1704
+ /**
1705
+ * Called when the service cannot expire an external payment session that was
1706
+ * created for a message batch that did not commit.
1707
+ */
1708
+ onPaymentCompensationFailure?: (event: PaymentCompensationFailureEvent) => void | Promise<void>;
1709
+ /**
1710
+ * Observes `endSession()` rejection for service-owned batch/action transactions.
1711
+ * Confirmed commits and primary transaction errors are preserved even if this
1712
+ * observer throws/rejects. Awaited best-effort diagnostics; keep it bounded.
1713
+ * Configure `buildMessageSchema` separately for direct document `archive()`.
1714
+ */
1715
+ onTransactionCleanupFailure?: MessageTransactionCleanupFailureObserver;
1716
+ adminRoles?: string[];
1717
+ registry?: TemplateRegistry;
1718
+ /**
1719
+ * Maximum number of messages returned by `listMessages` when no explicit
1720
+ * limit is provided. Defaults to 50.
1721
+ */
1722
+ defaultListLimit?: number;
1723
+ /**
1724
+ * Hard upper bound for `listMessages` limit to prevent abuse. Defaults to 100.
1725
+ */
1726
+ maxListLimit?: number;
1727
+ /**
1728
+ * How long a pending idempotent create reservation lease remains live before
1729
+ * another caller may atomically take it over. Defaults to 30000 ms.
1730
+ *
1731
+ * Must be a finite safe integer in `[1, 2147483647]` ms (positive lease).
1732
+ * The upper bound is Node's maximum `setTimeout` delay (`2^31 - 1`); larger
1733
+ * values would not fire on the intended cadence. Fractional, `NaN`,
1734
+ * infinite, negative, zero, or overflowing values throw
1735
+ * `InvalidMessageServiceOptionError` at construction.
1736
+ */
1737
+ clientRequestLeaseMs?: number;
1738
+ /**
1739
+ * Maximum time a duplicate idempotent create waits for completion or stale
1740
+ * lease takeover before raising `ClientRequestPendingError`. Defaults to 5000 ms.
1741
+ *
1742
+ * Must be a finite safe integer in `[0, 2147483647]` ms (nonnegative wait).
1743
+ * `0` is a supported immediate check: a duplicate that cannot acquire or
1744
+ * replay on its first attempt raises `ClientRequestPendingError` without
1745
+ * sleeping. The wait deadline bounds polling only — it never cancels a hung
1746
+ * database/provider operation. Out-of-range values throw
1747
+ * `InvalidMessageServiceOptionError` at construction.
1748
+ */
1749
+ clientRequestWaitMs?: number;
1750
+ /**
1751
+ * Poll interval while waiting for an idempotent create outcome. Defaults to 200 ms.
1752
+ *
1753
+ * Must be a finite safe integer in `[1, 2147483647]` ms (positive poll).
1754
+ * Out-of-range values throw `InvalidMessageServiceOptionError` at construction.
1755
+ */
1756
+ clientRequestPollMs?: number;
1757
+ /** Test hook for deterministic waiting; production uses `setTimeout`. */
1758
+ clientRequestDelay?: (ms: number) => Promise<void>;
1759
+ /**
1760
+ * Test hook for deterministic time. Defaults to `Date.now`.
1761
+ *
1762
+ * The same clock drives the duplicate-wait deadline and the persisted
1763
+ * `leaseExpiresAt` wall-clock timestamps (`new Date(now())`), so fake
1764
+ * clocks stay coherent in tests. The stale-takeover filter compares
1765
+ * `leaseExpiresAt` against this clock, not an independent `new Date()`.
1766
+ * Production uses real wall-clock time.
1767
+ */
1768
+ clientRequestNow?: () => number;
1769
+ }
1770
+ interface MessageServiceModelNames {
1771
+ active: string;
1772
+ archive: string;
1773
+ request: string;
1774
+ user: string;
1775
+ }
1776
+ interface PaymentCompensationFailureEvent {
1777
+ operation: 'expire';
1778
+ sessionId: string;
1779
+ error: unknown;
1780
+ originalError: unknown;
1781
+ clientRequestId?: string;
1782
+ clientRequestOwnerId?: string;
1783
+ templateCd?: string;
1784
+ }
1785
+ /**
1786
+ * Maximum platform timer delay (`2^31 - 1` ms, ~24.8 days). Node's
1787
+ * `setTimeout` clamps/overflows beyond this, so lease/wait/poll values above
1788
+ * it are rejected at construction rather than silently misfiring.
1789
+ */
1790
+ declare const MAX_MESSAGE_SERVICE_TIMEOUT_MS = 2147483647;
1791
+ type HydratedModelSource = {
1792
+ constructor: unknown;
1793
+ };
1794
+ /**
1795
+ * The `templateCd` used for generic notifications created via
1796
+ * `createNotification`. These messages have no actions, so
1797
+ * `getActions` returns an empty list for them.
1798
+ */
1799
+ declare const GENERIC_NOTIFICATION_TEMPLATE_CD = "__generic-notification__";
1800
+ /**
1801
+ * Shared principal-validation contract for user-facing service entry points
1802
+ * and HTTP routes.
1803
+ *
1804
+ * A valid principal id is a non-empty string (after trimming) or a
1805
+ * `mongoose.Types.ObjectId` instance. Missing, null, empty/whitespace-only,
1806
+ * numeric, array, and plain-object ids are invalid. Valid string ids keep
1807
+ * their string type (trimmed); valid ObjectId instances are preserved as
1808
+ * ObjectId by callers and normalized to their hex string only where a string
1809
+ * scope/query key is required.
1810
+ *
1811
+ * `findMessage`/`findMessageOrThrow` and `createNotification` are trusted
1812
+ * host-level operations and intentionally do not take a principal; they are
1813
+ * not part of this contract.
1814
+ */
1815
+ declare function isValidMessageUserId(id: unknown): id is string | mongoose.Types.ObjectId;
1816
+ /**
1817
+ * Assert a user-facing principal and return its normalized string identity.
1818
+ * Throws `InvalidMessageUserError` for missing/null/empty/numeric/array/
1819
+ * plain-object ids. String ids are trimmed; ObjectId ids become their hex
1820
+ * string. The caller's original `MessageUser` object (and its `_id` type) is
1821
+ * left untouched so ObjectId-backed schemas keep their native type.
1822
+ */
1823
+ declare function requireMessageUserId(user: unknown): string;
1507
1824
  /**
1508
1825
  * Core message service for creating messages, getting actions, and handling actions.
1509
1826
  *
@@ -1518,6 +1835,7 @@ declare class MessageService {
1518
1835
  private expirePaymentSession?;
1519
1836
  private refundPaymentSession?;
1520
1837
  private onPaymentCompensationFailure?;
1838
+ private onTransactionCleanupFailure?;
1521
1839
  private adminRoles;
1522
1840
  private registry;
1523
1841
  private defaultListLimit;
@@ -1526,20 +1844,51 @@ declare class MessageService {
1526
1844
  private clientRequestWaitMs;
1527
1845
  private clientRequestPollMs;
1528
1846
  private clientRequestDelay;
1847
+ private clientRequestNow;
1529
1848
  private actionLeaseMs;
1530
1849
  constructor(options: MessageServiceOptions);
1531
1850
  /**
1532
1851
  * Find a message by id, falling back to the archive. Returns null if
1533
1852
  * the message does not exist in either collection.
1853
+ *
1854
+ * Trusted host-level operation: takes no principal and performs no
1855
+ * user-identity validation. Hosts must authorize the returned document
1856
+ * before exposing it to a user (see `getActions`/`handleAction`).
1534
1857
  */
1535
1858
  findMessage(messageId: string, options?: {
1536
1859
  populate?: string | string[] | mongoose.PopulateOptions | mongoose.PopulateOptions[];
1537
1860
  select?: string | Record<string, 0 | 1 | boolean>;
1538
1861
  }): Promise<IMessage | IMessageArchive | null>;
1862
+ /**
1863
+ * Same as `findMessage`, but throws `MessageNotFoundError` when neither the
1864
+ * active nor the archive collection holds the id.
1865
+ *
1866
+ * Trusted host-level operation: no principal validation. Hosts must
1867
+ * authorize before exposing the result to a user.
1868
+ */
1539
1869
  findMessageOrThrow(messageId: string, options?: {
1540
1870
  populate?: string | string[] | mongoose.PopulateOptions | mongoose.PopulateOptions[];
1541
1871
  select?: string | Record<string, 0 | 1 | boolean>;
1542
1872
  }): Promise<IMessage | IMessageArchive>;
1873
+ /**
1874
+ * Create messages from a trusted template as an authenticated user.
1875
+ *
1876
+ * User-facing entry point: `user` (and `payerUser` when provided) must
1877
+ * carry a valid principal id (non-empty string or ObjectId; numbers,
1878
+ * arrays, and plain objects are rejected with `InvalidMessageUserError`)
1879
+ * before any template, provider, or model effect runs. Both the
1880
+ * idempotent (`clientRequestId`) and direct branches enforce this.
1881
+ *
1882
+ * Idempotent replay contract (MSGF-06): a completed same-scope replay
1883
+ * returns the current active/archive records merged from both collections
1884
+ * (`Array<IMessage | IMessageArchive>`), sorted by exact
1885
+ * `clientRequestItemIndex`, without rerunning preparation/payment. Fresh
1886
+ * creates resolve active documents only. Archive entries are returned as
1887
+ * persisted `IMessageArchive` — never cast to `IMessage` and never given
1888
+ * fabricated active-only methods. Narrow with `'archivedAt' in doc`.
1889
+ * Archive retention bounds the replay window: deleted archive documents
1890
+ * surface as `ClientRequestInconsistentStateError`, not partial replays.
1891
+ */
1543
1892
  createMessage(params: {
1544
1893
  templateCd: string;
1545
1894
  user: MessageUser;
@@ -1555,9 +1904,16 @@ declare class MessageService {
1555
1904
  * characters. Replays are scoped to the requester identity and template.
1556
1905
  */
1557
1906
  clientRequestId?: unknown;
1558
- }): Promise<IMessage[]>;
1907
+ }): Promise<Array<IMessage | IMessageArchive>>;
1559
1908
  private createMessageWithReservation;
1560
1909
  private createPreparedMessageBatch;
1910
+ /**
1911
+ * Create a generic notification without template preparation or actions.
1912
+ *
1913
+ * Trusted host-level operation: takes raw `UserId` values (string or
1914
+ * ObjectId, including null) and performs no principal validation. Hosts
1915
+ * own authentication/authorization for notification creation.
1916
+ */
1561
1917
  createNotification(params: {
1562
1918
  fromUser?: UserId | null;
1563
1919
  toUser?: UserId | null;
@@ -1578,12 +1934,17 @@ declare class MessageService {
1578
1934
  * Build a Mongoose filter for messages visible to the given user.
1579
1935
  * Exposed so callers can use the same visibility rules for custom
1580
1936
  * queries (e.g. with `populate`).
1937
+ *
1938
+ * User-facing helper: validates `user` before building the filter.
1581
1939
  */
1582
1940
  buildVisibilityFilter(user: MessageUser): Record<string, unknown>;
1583
1941
  /**
1584
1942
  * List active (non-archived) messages visible to a user.
1585
1943
  * Returns messages where the user is the sender, the receiver,
1586
1944
  * or matches one of the recipient's roles.
1945
+ *
1946
+ * User-facing operation: validates `user` via the shared principal
1947
+ * contract. Returns `IMessage[]` ordered by `{ createdAt: -1, _id: -1 }`.
1587
1948
  */
1588
1949
  listMessages(params: {
1589
1950
  user: MessageUser;
@@ -1594,18 +1955,57 @@ declare class MessageService {
1594
1955
  /**
1595
1956
  * Count active (non-archived) messages visible to a user.
1596
1957
  * Useful for badge indicators ("3 new messages").
1958
+ *
1959
+ * User-facing operation: validates `user` via the shared principal contract.
1597
1960
  */
1598
1961
  countMessages(user: MessageUser): Promise<number>;
1599
- getActions(messageId: string, usertype: Usertype, options?: {
1962
+ /**
1963
+ * Get available actions for a message as an authenticated user.
1964
+ *
1965
+ * User-facing operation: `options.user` is required and must carry a valid
1966
+ * principal id (non-empty string or ObjectId). It is validated before any
1967
+ * model lookup or template operation; invalid principals throw
1968
+ * `InvalidMessageUserError`.
1969
+ *
1970
+ * Returns `{ uiTemplate, actions }` for visible messages, or `null` when the
1971
+ * message is missing, the user has no sender/receiver relationship (unless
1972
+ * `isAdmin`), or the template is unknown. Admin (`isAdmin`) and archived
1973
+ * views return the resolved `uiTemplate` with an empty action list without
1974
+ * evaluating action `condition` predicates. All listings
1975
+ * read the requested id from the configured store. Relationships are
1976
+ * checked on canonical stored IDs before optional presentation population.
1977
+ * Conditions see that read snapshot with `populate` applied (mutations still
1978
+ * check unpopulated atomic claims); this does not reserve an action or prevent
1979
+ * subsequent host writes. Action labels and
1980
+ * confirmations render from persisted `message.payload` (missing values
1981
+ * render empty), distinct from creation-time `templateData`.
1982
+ */
1983
+ getActions(messageId: string, usertype: Usertype, options: {
1984
+ /** Authenticated caller; required. Validated before any effect. */
1985
+ user: MessageUser;
1600
1986
  permissions?: Record<string, boolean>;
1987
+ /** @deprecated Ignored. The requested id is always re-read from storage; omit this option. */
1601
1988
  message?: IMessage | IMessageArchive;
1602
- user?: MessageUser;
1603
1989
  isAdmin?: boolean;
1990
+ /** Presentation population for eligible active conditions, after relationship authorization. */
1604
1991
  populate?: string | string[] | mongoose.PopulateOptions | mongoose.PopulateOptions[];
1605
1992
  }): Promise<{
1606
1993
  uiTemplate: UiTemplate;
1607
1994
  actions: InterpolatedAction[];
1608
1995
  } | null>;
1996
+ /**
1997
+ * Execute an action with a durable claim, stable handler attempt key,
1998
+ * transactional archive, and post-commit sender notification status.
1999
+ *
2000
+ * User-facing operation: `data.user` is validated before any template or
2001
+ * model effect. Authorization and handler selection bind to the persisted
2002
+ * claim, not the caller-supplied `data.message` copy. Archived messages
2003
+ * are re-read by id on their owning connection and apply a stored
2004
+ * sender/receiver relationship gate before disclosing current attempt IDs
2005
+ * or notification state. A missing archive throws `MessageNotFoundError`.
2006
+ * Returns the handler's value (`unknown`; templates
2007
+ * document their own shape).
2008
+ */
1609
2009
  handleAction(templateCd: string, actionCd: string, data: {
1610
2010
  message: IMessage | IMessageArchive;
1611
2011
  user: MessageUser;
@@ -1624,17 +2024,61 @@ declare class MessageService {
1624
2024
  private compensatePaymentSession;
1625
2025
  private buildActionContext;
1626
2026
  private claimAction;
2027
+ /**
2028
+ * Relationship/permission policy for archived outcomes.
2029
+ *
2030
+ * Only the recorded sender or receiver may observe whether an archived
2031
+ * message is pending notification or terminally archived. This keeps
2032
+ * authorized retries (including pending-notification retries) available even
2033
+ * when the template has been unregistered, while unrelated callers receive a
2034
+ * stable `ActionNotAllowedError` without attempt IDs or notification state.
2035
+ */
2036
+ private authorizeArchivedOutcome;
2037
+ /** Call only with an authoritative, unpopulated archive read. */
2038
+ private throwArchivedOutcome;
2039
+ /**
2040
+ * Authoritative post-claim authorization bound to the persisted document
2041
+ * returned by the atomic claim. Re-validates the requested template/action
2042
+ * selection and the sender/receiver, permission, and condition checks
2043
+ * against persisted `templateCd`, parties, roles, and payload data.
2044
+ *
2045
+ * Supported update protocol: the guarantee covers the document state as of
2046
+ * the atomic claim (`findOneAndUpdate` returning the persisted document).
2047
+ * Host writes that commit before the claim are observed; host writes that
2048
+ * commit after the claim (during handler execution) are not prevented. Hosts
2049
+ * must not mutate action-relevant fields (`templateCd`, `toUser`/`toRoles`/
2050
+ * `fromUser`, condition payload) concurrently with in-flight actions outside
2051
+ * a coordinated protocol. A fresh-document read alone is not a substitute
2052
+ * for this claim-bound check.
2053
+ */
2054
+ private authorizeClaimedAction;
2055
+ private releaseDeniedActionClaim;
1627
2056
  private markActionRetryable;
1628
2057
  private archiveClaimedMessage;
1629
2058
  private markActionNotificationState;
1630
2059
  private runSenderNotification;
1631
2060
  private normalizeClientRequestId;
1632
2061
  private validatePositiveIntegerOption;
2062
+ private validateDurationOption;
1633
2063
  private normalizeListLimit;
1634
2064
  private normalizeListSkip;
1635
2065
  private buildClientRequestScope;
1636
2066
  private requireUserId;
1637
2067
  private findByClientRequestScope;
2068
+ private findArchivedByClientRequestScope;
2069
+ /**
2070
+ * Merge one active snapshot and one archive snapshot for a completed scope.
2071
+ *
2072
+ * Archival moves a document with create-then-delete ordering, so with an
2073
+ * active-then-archive read order a moving item is always visible in at least
2074
+ * one snapshot (no miss). A commit landing between the two reads surfaces
2075
+ * the same `_id` in both snapshots; deduplicating by `_id` string keeps the
2076
+ * active copy and prevents double-counting. Returns the merged batch sorted
2077
+ * by exact `clientRequestItemIndex`, or `null` when the merged set does not
2078
+ * satisfy the exact distinct-index contract.
2079
+ */
2080
+ private mergeReplayBatch;
2081
+ private buildCompletedReplay;
1638
2082
  private findCompletedClientRequestReplay;
1639
2083
  private acquireClientRequestStart;
1640
2084
  private tryAcquireClientRequestLease;
@@ -1642,57 +2086,198 @@ declare class MessageService {
1642
2086
  private tryTakeOverStaleClientRequestReservation;
1643
2087
  private findClientRequestReservation;
1644
2088
  private failClientRequestReservation;
1645
- private isDuplicateKeyError;
1646
- private isTransactionSupportError;
1647
2089
  private delay;
1648
2090
  private isArchivedMessage;
1649
2091
  private applyPopulate;
1650
2092
  private findByIdWithOptions;
1651
2093
  }
1652
2094
 
1653
- interface MessageRoutesOptions {
1654
- /** Mongoose model getter */
1655
- getModel: (name: string) => mongoose.Model<unknown>;
1656
- /** Payment provider (optional — enables payment session handling) */
1657
- paymentProvider?: PaymentProvider | null;
1658
- /** Optional hook called when expiring an uncommitted payment session fails. */
1659
- onPaymentCompensationFailure?: MessageServiceOptions['onPaymentCompensationFailure'];
1660
- /** Admin roles that receive messages when no toUser/toRoles specified */
1661
- adminRoles?: string[];
1662
- /**
1663
- * Custom template registry. Use this to isolate templates per app or test
1664
- * instead of relying on the global `defaultRegistry`. Pass the same registry
1665
- * instance to `MessageService` if you also construct one directly.
1666
- */
1667
- registry?: TemplateRegistry;
2095
+ /** A populated party exposes only its identity and optional presentation fields. */
2096
+ interface PublicMessageParty {
2097
+ _id: string;
2098
+ displayName?: string;
2099
+ email?: string;
2100
+ }
2101
+ /**
2102
+ * Allowlisted HTTP message representation, shared by create/replay and host lists.
2103
+ * IDs are strings and dates are ISO strings. Archive-only fields are absent on
2104
+ * active messages. Payload/display remain host-controlled business data.
2105
+ */
2106
+ interface PublicMessageDto {
2107
+ _id: string;
2108
+ templateCd: string;
2109
+ type: MessageType;
2110
+ fromUser: string | PublicMessageParty | null;
2111
+ toUser: string | PublicMessageParty | null;
2112
+ toRoles: string[];
2113
+ senderContent: IMessageContent;
2114
+ receiverContent: IMessageContent;
2115
+ documents: string[];
2116
+ /** Business payment session reference, intentionally available to clients. */
2117
+ paymentSession: string | null;
2118
+ paymentCd: string;
2119
+ payload: Record<string, unknown>;
2120
+ display: Record<string, unknown>;
2121
+ actionState: ActionLifecycleState;
2122
+ actionCd: string | null;
2123
+ /** Stable business deduplication ID, not the internal worker ownership token. */
2124
+ actionAttemptId: string | null;
2125
+ createdAt: string;
2126
+ updatedAt: string;
2127
+ archivedBy?: string | null;
2128
+ archivedAt?: string;
2129
+ actionNotificationState?: ActionNotificationState;
2130
+ }
2131
+ /**
2132
+ * Structural input for hydrated or plain stored messages, including populated
2133
+ * references. Unknown reference values are inspected only for IDs/presentation;
2134
+ * missing or unsupported parties become null, never arbitrary serialized objects.
2135
+ */
2136
+ type PublicMessageSource = Pick<IBaseMessage, 'templateCd' | 'type' | 'toRoles' | 'senderContent' | 'receiverContent' | 'paymentSession' | 'paymentCd' | 'payload' | 'display' | 'actionState' | 'actionCd' | 'actionAttemptId' | 'createdAt' | 'updatedAt'> & {
2137
+ _id: unknown;
2138
+ fromUser: unknown;
2139
+ toUser: unknown;
2140
+ documents: readonly unknown[];
2141
+ archivedBy?: unknown;
2142
+ archivedAt?: Date;
2143
+ actionNotificationState?: ActionNotificationState;
2144
+ };
2145
+ /**
2146
+ * Serialize an already-authorized message without mutating its hydrated record.
2147
+ * Explicitly excludes diagnostics, owner tokens, claim/lease/request bookkeeping,
2148
+ * schema extensions and arbitrary populated-user fields. Does not authorize or
2149
+ * sanitize host payload/display/content or action handler results. Only populate
2150
+ * email/displayName when they are appropriate for the audience.
2151
+ *
2152
+ * @example
2153
+ * const data = (await service.listMessages({ user })).map(serializePublicMessage);
2154
+ */
2155
+ declare function serializePublicMessage(message: PublicMessageSource): PublicMessageDto;
2156
+
2157
+ /**
2158
+ * Route-only behavior options shared by both composition paths.
2159
+ *
2160
+ * These never configure the underlying `MessageService`: auth middleware,
2161
+ * request extractors, and the admin read-only permission key. They apply
2162
+ * identically whether routes construct a service or reuse an injected one.
2163
+ */
2164
+ interface MessageRoutesBehaviorOptions {
1668
2165
  /** Custom auth middleware applied to all routes */
1669
2166
  authMiddleware?: ((req: Request, res: Response, next: NextFunction) => void)[];
1670
2167
  /**
1671
2168
  * Extract user from request (default: req._user || req.user). All routes
1672
- * require this extractor to return a user with a non-empty `_id` before any
1673
- * service, template, payment, model, or action side effect runs.
2169
+ * require this extractor to return a user with a valid `_id` (non-empty
2170
+ * string or ObjectId; numbers, arrays, and plain objects are rejected)
2171
+ * before any service, template, payment, model, or action side effect runs.
2172
+ * Shares the service's principal-validation contract.
1674
2173
  */
1675
2174
  getUser?: (req: Request) => MessageUser | undefined;
1676
- /** Extract permissions from request (default: req._permissions || {}) */
2175
+ /**
2176
+ * Extract permissions from request (default: req._permissions || {}).
2177
+ * Permission grants throughout the package (UI filtering, action
2178
+ * execution, admin read-only) require an own property strictly equal to
2179
+ * `true`; custom extractors must return plain own-boolean maps. Inherited
2180
+ * properties and truthy non-booleans are denied.
2181
+ */
1677
2182
  getPermissions?: (req: Request) => Record<string, boolean>;
1678
2183
  /** Extract identity from request (default: req._identity || {}) */
1679
2184
  getIdentity?: (req: Request) => Record<string, unknown>;
1680
2185
  /**
1681
- * Permission key that, when truthy, makes `getActions` return an empty
1682
- * action list (read-only view). Defaults to `'is.admin'`.
2186
+ * Permission key that, when granted as an own boolean `true`, makes
2187
+ * `getActions` return an empty action list (read-only view). Defaults to
2188
+ * `'is.admin'`. Uses the same explicit-grant predicate as UI filtering and
2189
+ * action execution, so inheritance (e.g. `constructor`) or truthy
2190
+ * non-booleans cannot enable admin read-only mode.
1683
2191
  */
1684
2192
  adminPermissionKey?: string;
1685
2193
  }
2194
+ /**
2195
+ * Convenience-construction path: routes build and own a `MessageService`
2196
+ * from the full service option set. `service` must be absent; every other
2197
+ * service knob (`getModel`, `connection`, `modelNames`, payment, `registry`,
2198
+ * list limits, and `clientRequest*` timing/test hooks) is forwarded verbatim
2199
+ * to `new MessageService(...)` so the route service never drifts behind the
2200
+ * direct-service configuration surface.
2201
+ */
2202
+ interface MessageRoutesConstructionOptions extends MessageRoutesBehaviorOptions, MessageServiceOptions {
2203
+ /**
2204
+ * Must be absent on this path. Supplying `service` together with any
2205
+ * service construction option is a conflict (see
2206
+ * `MessageRoutesInjectionOptions`); the factory throws
2207
+ * `InvalidMessageServiceOptionError` instead of silently ignoring options.
2208
+ */
2209
+ service?: undefined;
2210
+ /** Mongoose model getter (required on this path for backwards compatibility). */
2211
+ getModel: NonNullable<MessageServiceOptions['getModel']>;
2212
+ }
2213
+ /**
2214
+ * Service-injection path: routes reuse the exact supplied configured
2215
+ * service (custom `modelNames`, `connection`, timing policies, providers,
2216
+ * and registry included). No second service or global registry is created.
2217
+ * Every service construction option is typed `never` here and rejected at
2218
+ * runtime; route-only behavior options remain allowed.
2219
+ */
2220
+ interface MessageRoutesInjectionOptions extends MessageRoutesBehaviorOptions {
2221
+ /** Existing configured service reused verbatim (`returned.service === supplied`). */
2222
+ service: MessageService;
2223
+ getModel?: never;
2224
+ connection?: never;
2225
+ modelNames?: never;
2226
+ paymentProvider?: never;
2227
+ onPaymentCompensationFailure?: never;
2228
+ onTransactionCleanupFailure?: never;
2229
+ adminRoles?: never;
2230
+ registry?: never;
2231
+ defaultListLimit?: never;
2232
+ maxListLimit?: never;
2233
+ clientRequestLeaseMs?: never;
2234
+ clientRequestWaitMs?: never;
2235
+ clientRequestPollMs?: never;
2236
+ clientRequestDelay?: never;
2237
+ clientRequestNow?: never;
2238
+ }
2239
+ /**
2240
+ * Supported route composition options: either construct a service from
2241
+ * service options or reuse an existing service — never both.
2242
+ *
2243
+ * - Construction: `createMessageRoutes({ getModel, registry, ... })`.
2244
+ * - Injection: `createMessageRoutes({ service, getUser, ... })`.
2245
+ *
2246
+ * Combining `service` with any service construction option throws
2247
+ * `InvalidMessageServiceOptionError` at runtime (and fails strict type
2248
+ * checking via the `never` fields on the injection branch) rather than
2249
+ * silently ignoring the conflicting options.
2250
+ */
2251
+ type MessageRoutesOptions = MessageRoutesConstructionOptions | MessageRoutesInjectionOptions;
1686
2252
  /**
1687
2253
  * Create a JsonRouter with the message template routes.
1688
2254
  * Mount via `router.original` and apply your own auth/permission middleware.
1689
2255
  *
2256
+ * Composition (MSGF-11):
2257
+ * - Convenience construction: `createMessageRoutes({ getModel, registry,
2258
+ * clientRequestWaitMs, ... })` builds and owns a `MessageService` from the
2259
+ * full service option set (no subsets).
2260
+ * - Service injection: `createMessageRoutes({ service })` reuses the exact
2261
+ * supplied configured service — custom `modelNames`, `connection`, timing
2262
+ * policies, providers, and registry included — without creating a second
2263
+ * service or touching the global registry. Route-only behavior options
2264
+ * (`authMiddleware`, `getUser`, `getPermissions`, `getIdentity`,
2265
+ * `adminPermissionKey`) still apply on both paths.
2266
+ *
2267
+ * Combining `service` with any service construction option throws
2268
+ * `InvalidMessageServiceOptionError` instead of silently ignoring conflicts.
2269
+ * No per-method service callbacks are accepted; routes call the service API
2270
+ * directly so authentication/validation/error mapping stay identical on both
2271
+ * paths.
2272
+ *
1690
2273
  * Routes:
1691
- * POST /new/:templateCd — create message from template
2274
+ * POST /new/:templateCd — create/replay as PublicMessageDto[]
1692
2275
  * GET /:id/actions/:usertype — get available actions for a message
1693
2276
  * POST /:id/action/:actionCd — execute an action (POST)
1694
2277
  *
1695
- * All routes require a resolved user with a non-empty `_id`. Route parameters
2278
+ * All routes require a resolved user with a valid `_id` (non-empty string
2279
+ * or ObjectId; numbers, arrays, and plain objects are rejected with 401).
2280
+ * Route parameters
1696
2281
  * are validated before service/model/template lookup, and mutating actions are
1697
2282
  * intentionally POST-only.
1698
2283
  */
@@ -1701,4 +2286,4 @@ declare function createMessageRoutes(options: MessageRoutesOptions): {
1701
2286
  service: MessageService;
1702
2287
  };
1703
2288
 
1704
- export { type ActionConfirmation, ActionConflictError, type ActionContext, type ActionLifecycleState, ActionNotAllowedError, ActionNotFoundError, ActionNotificationPendingError, type ActionNotificationState, ActionRetryableError, ActionTemplateMismatchError, BaseMessageFields, ClientRequestFailedError, ClientRequestInconsistentStateError, ClientRequestPendingError, type EmailDeliveryFailureEvent, type EmailDeliveryFailureStage, type EmailNotifier, type EmailProvider, GENERIC_NOTIFICATION_TEMPLATE_CD, type IBaseMessage, type IMessage, type IMessageArchive, type IMessageContent, type IMessageMethods, type InterpolatedAction, type InterpolatedContent, type InterpolationResult, InvalidClientRequestIdError, InvalidMessageUserError, InvalidPaginationValueError, MESSAGE_ARCHIVE_MODEL_NAME, MESSAGE_MODEL_NAME, MESSAGE_REQUEST_MODEL_NAME, type MessageAction, MessageArchiveSchema, MessageArchivedError, MessageContentSchema, MessageModelResolutionError, MessageNotFoundError, MessageRequestSchema, type MessageRoutesOptions, MessageSchema, type MessageSchemaConfig, MessageService, type MessageServiceModelNames, type MessageServiceOptions, type MessageTemplate, MessageTransactionRequiredError, type MessageType, type MessageUser, NoopEmailProvider, NoopPaymentProvider, type PaymentCompensationFailureEvent, type PaymentProvider, PaymentSessionCompensationError, type PrepareContext, type PrepareResult, type SenderNotificationContent, TemplateNotFoundError, TemplateRegistry, TemplateRegistryValidationError, type UiTemplate, type UserId, type Usertype, buildMessageArchiveSchema, buildMessageRequestSchema, buildMessageSchema, createMessageRoutes, defaultRegistry, filterActions, includesAction, interpolateTemplate, isActionAllowed };
2289
+ export { type ActionConfirmation, ActionConflictError, type ActionContext, type ActionLifecycleState, ActionNotAllowedError, ActionNotFoundError, ActionNotificationPendingError, type ActionNotificationState, ActionRetryableError, ActionTemplateMismatchError, BaseMessageFields, ClientRequestFailedError, ClientRequestInconsistentStateError, ClientRequestPendingError, type EmailDeliveryFailureEvent, type EmailDeliveryFailureStage, type EmailNotifier, type EmailProvider, GENERIC_NOTIFICATION_TEMPLATE_CD, type IBaseMessage, type IMessage, type IMessageArchive, type IMessageContent, type IMessageMethods, type InterpolatedAction, type InterpolatedContent, type InterpolationResult, InvalidClientRequestIdError, InvalidMessageServiceOptionError, InvalidMessageUserError, InvalidPaginationValueError, MAX_MESSAGE_SERVICE_TIMEOUT_MS, MESSAGE_ARCHIVE_MODEL_NAME, MESSAGE_MODEL_NAME, MESSAGE_REQUEST_MODEL_NAME, type MessageAction, MessageArchiveSchema, MessageArchivedError, MessageContentSchema, MessageModelResolutionError, MessageNotFoundError, MessageRequestSchema, type MessageRoutesBehaviorOptions, type MessageRoutesConstructionOptions, type MessageRoutesInjectionOptions, type MessageRoutesOptions, MessageSchema, type MessageSchemaConfig, MessageService, type MessageServiceModelNames, type MessageServiceOptions, type MessageTemplate, type MessageTransactionCleanupFailureEvent, type MessageTransactionCleanupFailureObserver, MessageTransactionRequiredError, type MessageType, type MessageUser, NoopEmailProvider, NoopPaymentProvider, type PaymentCompensationFailureEvent, type PaymentProvider, PaymentSessionCompensationAggregateError, PaymentSessionCompensationError, type PaymentSessionCompensationFailure, type PrepareContext, type PrepareResult, type PublicMessageDto, type PublicMessageParty, type PublicMessageSource, type RegisteredMessageAction, type RegisteredMessageTemplate, type RegisteredUiTemplate, type SenderNotificationContent, TemplateNotFoundError, TemplateRegistry, TemplateRegistryValidationError, type UiTemplate, type UserId, type Usertype, buildMessageArchiveSchema, buildMessageRequestSchema, buildMessageSchema, createMessageRoutes, defaultRegistry, filterActions, hasExplicitPermissionGrant, includesAction, interpolateMessageContent, interpolateTemplate, isActionAllowed, isValidMessageUserId, requireMessageUserId, resolveUiTemplate, serializePublicMessage };