@atlasauth/emulator-core 0.2.0 → 0.3.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 (248) hide show
  1. package/LICENSE +21 -0
  2. package/dist/custom-html.d.ts +57 -0
  3. package/dist/custom-html.js +491 -0
  4. package/dist/custom-html.js.map +1 -0
  5. package/dist/engine.d.ts +1 -1
  6. package/dist/engine.js +5 -1
  7. package/dist/engine.js.map +1 -1
  8. package/dist/error-catalog.js +8 -0
  9. package/dist/error-catalog.js.map +1 -1
  10. package/dist/event-gate.js +60 -2
  11. package/dist/event-gate.js.map +1 -1
  12. package/dist/generic.js +5 -1
  13. package/dist/generic.js.map +1 -1
  14. package/dist/handlers/access-signals.d.ts +48 -0
  15. package/dist/handlers/access-signals.js +76 -0
  16. package/dist/handlers/access-signals.js.map +1 -0
  17. package/dist/handlers/approvals.js +54 -1
  18. package/dist/handlers/approvals.js.map +1 -1
  19. package/dist/handlers/authz-data.d.ts +2 -0
  20. package/dist/handlers/authz-data.js +627 -0
  21. package/dist/handlers/authz-data.js.map +1 -0
  22. package/dist/handlers/authz-layers.d.ts +229 -0
  23. package/dist/handlers/authz-layers.js +786 -0
  24. package/dist/handlers/authz-layers.js.map +1 -0
  25. package/dist/handlers/authz-state.d.ts +80 -0
  26. package/dist/handlers/authz-state.js +175 -0
  27. package/dist/handlers/authz-state.js.map +1 -0
  28. package/dist/handlers/authz.js +240 -177
  29. package/dist/handlers/authz.js.map +1 -1
  30. package/dist/handlers/billing.d.ts +99 -1
  31. package/dist/handlers/billing.js +556 -77
  32. package/dist/handlers/billing.js.map +1 -1
  33. package/dist/handlers/cedar-ext.d.ts +35 -0
  34. package/dist/handlers/cedar-ext.js +174 -0
  35. package/dist/handlers/cedar-ext.js.map +1 -0
  36. package/dist/handlers/cedar.d.ts +146 -0
  37. package/dist/handlers/cedar.js +689 -0
  38. package/dist/handlers/cedar.js.map +1 -0
  39. package/dist/handlers/connections.d.ts +4 -1
  40. package/dist/handlers/connections.js +267 -38
  41. package/dist/handlers/connections.js.map +1 -1
  42. package/dist/handlers/control.js +6 -1
  43. package/dist/handlers/control.js.map +1 -1
  44. package/dist/handlers/device.js +18 -1
  45. package/dist/handlers/device.js.map +1 -1
  46. package/dist/handlers/emu3-misc.d.ts +38 -0
  47. package/dist/handlers/emu3-misc.js +1056 -0
  48. package/dist/handlers/emu3-misc.js.map +1 -0
  49. package/dist/handlers/emu4-actions-access.d.ts +46 -0
  50. package/dist/handlers/emu4-actions-access.js +107 -0
  51. package/dist/handlers/emu4-actions-access.js.map +1 -0
  52. package/dist/handlers/emu4-actions-catalog-extra.d.ts +3 -0
  53. package/dist/handlers/emu4-actions-catalog-extra.js +204 -0
  54. package/dist/handlers/emu4-actions-catalog-extra.js.map +1 -0
  55. package/dist/handlers/emu4-actions-catalog.d.ts +3 -0
  56. package/dist/handlers/emu4-actions-catalog.js +143 -0
  57. package/dist/handlers/emu4-actions-catalog.js.map +1 -0
  58. package/dist/handlers/emu4-actions-def.d.ts +50 -0
  59. package/dist/handlers/emu4-actions-def.js +250 -0
  60. package/dist/handlers/emu4-actions-def.js.map +1 -0
  61. package/dist/handlers/emu4-actions-sandbox.d.ts +36 -0
  62. package/dist/handlers/emu4-actions-sandbox.js +318 -0
  63. package/dist/handlers/emu4-actions-sandbox.js.map +1 -0
  64. package/dist/handlers/emu4-actions-typegen.d.ts +5 -0
  65. package/dist/handlers/emu4-actions-typegen.js +73 -0
  66. package/dist/handlers/emu4-actions-typegen.js.map +1 -0
  67. package/dist/handlers/emu4-actions.d.ts +2 -0
  68. package/dist/handlers/emu4-actions.js +506 -0
  69. package/dist/handlers/emu4-actions.js.map +1 -0
  70. package/dist/handlers/emu4-authz-trust.d.ts +40 -0
  71. package/dist/handlers/emu4-authz-trust.js +488 -0
  72. package/dist/handlers/emu4-authz-trust.js.map +1 -0
  73. package/dist/handlers/emu4-authz.d.ts +2 -0
  74. package/dist/handlers/emu4-authz.js +752 -0
  75. package/dist/handlers/emu4-authz.js.map +1 -0
  76. package/dist/handlers/emu4-billing-core.d.ts +23 -0
  77. package/dist/handlers/emu4-billing-core.js +22 -0
  78. package/dist/handlers/emu4-billing-core.js.map +1 -0
  79. package/dist/handlers/emu4-billing-x.d.ts +2 -0
  80. package/dist/handlers/emu4-billing-x.js +489 -0
  81. package/dist/handlers/emu4-billing-x.js.map +1 -0
  82. package/dist/handlers/emu4-billing.d.ts +14 -0
  83. package/dist/handlers/emu4-billing.js +846 -0
  84. package/dist/handlers/emu4-billing.js.map +1 -0
  85. package/dist/handlers/emu4-clones.d.ts +2 -0
  86. package/dist/handlers/emu4-clones.js +322 -0
  87. package/dist/handlers/emu4-clones.js.map +1 -0
  88. package/dist/handlers/emu4-compliance.d.ts +8 -0
  89. package/dist/handlers/emu4-compliance.js +39 -0
  90. package/dist/handlers/emu4-compliance.js.map +1 -0
  91. package/dist/handlers/emu4-config.d.ts +14 -0
  92. package/dist/handlers/emu4-config.js +148 -0
  93. package/dist/handlers/emu4-config.js.map +1 -0
  94. package/dist/handlers/emu4-cookies.d.ts +8 -0
  95. package/dist/handlers/emu4-cookies.js +248 -0
  96. package/dist/handlers/emu4-cookies.js.map +1 -0
  97. package/dist/handlers/emu4-erasure.d.ts +2 -0
  98. package/dist/handlers/emu4-erasure.js +587 -0
  99. package/dist/handlers/emu4-erasure.js.map +1 -0
  100. package/dist/handlers/emu4-federation.d.ts +8 -0
  101. package/dist/handlers/emu4-federation.js +18 -0
  102. package/dist/handlers/emu4-federation.js.map +1 -0
  103. package/dist/handlers/emu4-journey-math.d.ts +42 -0
  104. package/dist/handlers/emu4-journey-math.js +99 -0
  105. package/dist/handlers/emu4-journey-math.js.map +1 -0
  106. package/dist/handlers/emu4-messaging.d.ts +21 -0
  107. package/dist/handlers/emu4-messaging.js +595 -0
  108. package/dist/handlers/emu4-messaging.js.map +1 -0
  109. package/dist/handlers/emu4-minors.d.ts +7 -0
  110. package/dist/handlers/emu4-minors.js +408 -0
  111. package/dist/handlers/emu4-minors.js.map +1 -0
  112. package/dist/handlers/emu4-oauth-audit.d.ts +5 -0
  113. package/dist/handlers/emu4-oauth-audit.js +17 -0
  114. package/dist/handlers/emu4-oauth-audit.js.map +1 -0
  115. package/dist/handlers/emu4-oauth-auth.d.ts +83 -0
  116. package/dist/handlers/emu4-oauth-auth.js +363 -0
  117. package/dist/handlers/emu4-oauth-auth.js.map +1 -0
  118. package/dist/handlers/emu4-oauth-delivery.d.ts +68 -0
  119. package/dist/handlers/emu4-oauth-delivery.js +207 -0
  120. package/dist/handlers/emu4-oauth-delivery.js.map +1 -0
  121. package/dist/handlers/emu4-oauth-keys.d.ts +11 -0
  122. package/dist/handlers/emu4-oauth-keys.js +268 -0
  123. package/dist/handlers/emu4-oauth-keys.js.map +1 -0
  124. package/dist/handlers/emu4-oauth-pub.d.ts +36 -0
  125. package/dist/handlers/emu4-oauth-pub.js +420 -0
  126. package/dist/handlers/emu4-oauth-pub.js.map +1 -0
  127. package/dist/handlers/emu4-oauth.d.ts +10 -0
  128. package/dist/handlers/emu4-oauth.js +23 -0
  129. package/dist/handlers/emu4-oauth.js.map +1 -0
  130. package/dist/handlers/emu4-policies.d.ts +2 -0
  131. package/dist/handlers/emu4-policies.js +408 -0
  132. package/dist/handlers/emu4-policies.js.map +1 -0
  133. package/dist/handlers/emu4-region.d.ts +2 -0
  134. package/dist/handlers/emu4-region.js +189 -0
  135. package/dist/handlers/emu4-region.js.map +1 -0
  136. package/dist/handlers/emu4-saml.d.ts +12 -0
  137. package/dist/handlers/emu4-saml.js +532 -0
  138. package/dist/handlers/emu4-saml.js.map +1 -0
  139. package/dist/handlers/emu4-shared.d.ts +44 -0
  140. package/dist/handlers/emu4-shared.js +90 -0
  141. package/dist/handlers/emu4-shared.js.map +1 -0
  142. package/dist/handlers/emu4-snapshots.d.ts +38 -0
  143. package/dist/handlers/emu4-snapshots.js +231 -0
  144. package/dist/handlers/emu4-snapshots.js.map +1 -0
  145. package/dist/handlers/emu4-sns.d.ts +9 -0
  146. package/dist/handlers/emu4-sns.js +21 -0
  147. package/dist/handlers/emu4-sns.js.map +1 -0
  148. package/dist/handlers/emu4-trust-binding.d.ts +27 -0
  149. package/dist/handlers/emu4-trust-binding.js +344 -0
  150. package/dist/handlers/emu4-trust-binding.js.map +1 -0
  151. package/dist/handlers/emu4-trust.d.ts +35 -0
  152. package/dist/handlers/emu4-trust.js +543 -0
  153. package/dist/handlers/emu4-trust.js.map +1 -0
  154. package/dist/handlers/fga-engine.d.ts +78 -0
  155. package/dist/handlers/fga-engine.js +663 -0
  156. package/dist/handlers/fga-engine.js.map +1 -0
  157. package/dist/handlers/governance.js +3 -2
  158. package/dist/handlers/governance.js.map +1 -1
  159. package/dist/handlers/hooks.js +13 -0
  160. package/dist/handlers/hooks.js.map +1 -1
  161. package/dist/handlers/index.js +14 -1
  162. package/dist/handlers/index.js.map +1 -1
  163. package/dist/handlers/machines.js +6 -0
  164. package/dist/handlers/machines.js.map +1 -1
  165. package/dist/handlers/messaging.d.ts +3 -1
  166. package/dist/handlers/messaging.js +455 -48
  167. package/dist/handlers/messaging.js.map +1 -1
  168. package/dist/handlers/oauth-adv.d.ts +191 -0
  169. package/dist/handlers/oauth-adv.js +1305 -0
  170. package/dist/handlers/oauth-adv.js.map +1 -0
  171. package/dist/handlers/oauth-flows.d.ts +25 -1
  172. package/dist/handlers/oauth-flows.js +111 -50
  173. package/dist/handlers/oauth-flows.js.map +1 -1
  174. package/dist/handlers/oauth.d.ts +24 -3
  175. package/dist/handlers/oauth.js +245 -60
  176. package/dist/handlers/oauth.js.map +1 -1
  177. package/dist/handlers/orgs.js +6 -0
  178. package/dist/handlers/orgs.js.map +1 -1
  179. package/dist/handlers/platform.d.ts +5 -0
  180. package/dist/handlers/platform.js +33 -15
  181. package/dist/handlers/platform.js.map +1 -1
  182. package/dist/handlers/scim.d.ts +2 -0
  183. package/dist/handlers/scim.js +198 -11
  184. package/dist/handlers/scim.js.map +1 -1
  185. package/dist/handlers/sessions.d.ts +2 -0
  186. package/dist/handlers/sessions.js +24 -16
  187. package/dist/handlers/sessions.js.map +1 -1
  188. package/dist/handlers/users.js +3 -0
  189. package/dist/handlers/users.js.map +1 -1
  190. package/dist/jwe.d.ts +18 -0
  191. package/dist/jwe.js +101 -0
  192. package/dist/jwe.js.map +1 -0
  193. package/dist/jws.js +2 -0
  194. package/dist/jws.js.map +1 -1
  195. package/dist/messaging-callbacks.d.ts +100 -0
  196. package/dist/messaging-callbacks.js +767 -0
  197. package/dist/messaging-callbacks.js.map +1 -0
  198. package/dist/openapi.d.ts +1 -0
  199. package/dist/openapi.js +44 -2
  200. package/dist/openapi.js.map +1 -1
  201. package/dist/parity-emu3-authz.d.ts +3 -0
  202. package/dist/parity-emu3-authz.js +61 -0
  203. package/dist/parity-emu3-authz.js.map +1 -0
  204. package/dist/parity-emu3-billing.d.ts +3 -0
  205. package/dist/parity-emu3-billing.js +25 -0
  206. package/dist/parity-emu3-billing.js.map +1 -0
  207. package/dist/parity-emu3-messaging.d.ts +3 -0
  208. package/dist/parity-emu3-messaging.js +27 -0
  209. package/dist/parity-emu3-messaging.js.map +1 -0
  210. package/dist/parity-emu3-misc.d.ts +2 -0
  211. package/dist/parity-emu3-misc.js +63 -0
  212. package/dist/parity-emu3-misc.js.map +1 -0
  213. package/dist/parity-emu3-oauth.d.ts +2 -0
  214. package/dist/parity-emu3-oauth.js +39 -0
  215. package/dist/parity-emu3-oauth.js.map +1 -0
  216. package/dist/parity-emu4-actions.d.ts +2 -0
  217. package/dist/parity-emu4-actions.js +38 -0
  218. package/dist/parity-emu4-actions.js.map +1 -0
  219. package/dist/parity-emu4-authz.d.ts +3 -0
  220. package/dist/parity-emu4-authz.js +68 -0
  221. package/dist/parity-emu4-authz.js.map +1 -0
  222. package/dist/parity-emu4-billing.d.ts +6 -0
  223. package/dist/parity-emu4-billing.js +44 -0
  224. package/dist/parity-emu4-billing.js.map +1 -0
  225. package/dist/parity-emu4-compliance.d.ts +2 -0
  226. package/dist/parity-emu4-compliance.js +92 -0
  227. package/dist/parity-emu4-compliance.js.map +1 -0
  228. package/dist/parity-emu4-federation.d.ts +2 -0
  229. package/dist/parity-emu4-federation.js +33 -0
  230. package/dist/parity-emu4-federation.js.map +1 -0
  231. package/dist/parity-emu4-messaging.d.ts +3 -0
  232. package/dist/parity-emu4-messaging.js +42 -0
  233. package/dist/parity-emu4-messaging.js.map +1 -0
  234. package/dist/parity-emu4-oauth.d.ts +2 -0
  235. package/dist/parity-emu4-oauth.js +55 -0
  236. package/dist/parity-emu4-oauth.js.map +1 -0
  237. package/dist/parity-emu4-trust.d.ts +2 -0
  238. package/dist/parity-emu4-trust.js +41 -0
  239. package/dist/parity-emu4-trust.js.map +1 -0
  240. package/dist/parity-routes.js +19 -1
  241. package/dist/parity-routes.js.map +1 -1
  242. package/dist/sns-cert.d.ts +9 -0
  243. package/dist/sns-cert.js +101 -0
  244. package/dist/sns-cert.js.map +1 -0
  245. package/dist/tokens.d.ts +69 -4
  246. package/dist/tokens.js +164 -38
  247. package/dist/tokens.js.map +1 -1
  248. package/package.json +2 -2
@@ -1,12 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.keyStore = void 0;
3
4
  exports.installMessaging = installMessaging;
4
5
  exports.messagingHandlers = messagingHandlers;
5
6
  const crypto_util_1 = require("../crypto-util");
6
7
  const errors_1 = require("../errors");
8
+ const messaging_callbacks_1 = require("../messaging-callbacks");
7
9
  const store_1 = require("../store");
8
10
  const support_1 = require("./support");
9
11
  const util_1 = require("./util");
12
+ const emu4_sns_1 = require("./emu4-sns");
10
13
  /**
11
14
  * The messaging layer behind every email, SMS, WhatsApp, voice and push message the engine sends: the message ledger
12
15
  * (`GET /v1/messages`, one row per message with attempts and a delivery timeline), the suppression list (a suppressed
@@ -14,6 +17,14 @@ const util_1 = require("./util");
14
17
  * fallback providers tried when the primary fails) and per-country SMS routes. Nothing leaves the process: delivery
15
18
  * means the message reached `engine.messages`. To exercise failover, make a provider fail with
16
19
  * `POST /__emulator/messaging/faults`; to play a provider's delivery callback, `POST /__emulator/messaging/feedback`.
20
+ *
21
+ * Per-channel providers: `PUT /v1/messaging_providers/{email,sms,push}` picks the provider of a channel (WhatsApp and voice ride
22
+ * the SMS provider's Twilio account, as on the real API), every ledger row records the `provider` and `channel` it went through, and
23
+ * `POST /__emulator/messaging/require_providers` makes a channel with no provider fail instead of using the built-in sink.
24
+ *
25
+ * Provider callbacks: `POST /v1/webhooks/{provider}/{instance_id}` verifies each provider's own signature (or URL token) before
26
+ * it touches the ledger. `GET /__emulator/messaging/signing_keys` lists the test secrets and `POST /__emulator/messaging/sign`
27
+ * builds a correctly signed (or tampered, or stale) callback for a ledger message.
17
28
  */
18
29
  const CHANNELS = ['email', 'sms', 'whatsapp', 'voice', 'push'];
19
30
  const STATUSES = ['queued', 'sent', 'delivered', 'failed', 'bounced', 'complained', 'suppressed'];
@@ -25,17 +36,38 @@ const TRANSPORTS = {
25
36
  push: ['fcm', 'apns', 'onesignal'],
26
37
  };
27
38
  const RECEIVERS = [
28
- { provider: 'ses', channel: 'email', verification: 'signature', events: ['delivery', 'bounce', 'complaint'], secret: 'SNS topic ARNs' },
29
- { provider: 'sendgrid', channel: 'email', verification: 'signature', events: ['delivered', 'bounce', 'spamreport'], secret: 'signed event webhook public key' },
30
- { provider: 'brevo', channel: 'email', verification: 'token', events: ['delivered', 'hard_bounce', 'spam'], secret: null },
31
- { provider: 'mailgun', channel: 'email', verification: 'signature', events: ['delivered', 'failed', 'complained'], secret: 'HTTP webhook signing key' },
32
- { provider: 'postmark', channel: 'email', verification: 'token', events: ['Delivery', 'Bounce', 'SpamComplaint'], secret: null },
33
- { provider: 'resend', channel: 'email', verification: 'signature', events: ['email.delivered', 'email.bounced', 'email.complained'], secret: 'whsec_ signing secret' },
34
- { provider: 'smtp2go', channel: 'email', verification: 'token', events: ['delivered', 'bounce', 'spam'], secret: null },
35
- { provider: 'twilio', channel: 'sms', verification: 'signature', events: ['delivered', 'undelivered', 'failed'], secret: null },
36
- { provider: 'vonage', channel: 'sms', verification: 'signature_or_token', events: ['delivered', 'failed'], secret: 'signature secret' },
37
- { provider: 'messagebird', channel: 'sms', verification: 'signature', events: ['delivered', 'delivery_failed'], secret: 'signing key' },
39
+ { provider: 'ses', channel: 'email', verification: 'token', secret: 'Optional: comma-separated SNS topic ARNs to accept (any other topic is refused). Amazon SNS signatures are always verified.', events: 'Bounce, Complaint and Delivery notifications through an SNS topic subscribed over HTTPS.' },
40
+ { provider: 'brevo', channel: 'email', verification: 'token', secret: null, events: 'Transactional webhook: request, delivered, hard_bounce, soft_bounce, blocked, spam, invalid_email, unsubscribed, error. Send the token as a bearer header.' },
41
+ { provider: 'mailgun', channel: 'email', verification: 'signature', secret: 'Your Mailgun HTTP webhook signing key.', events: 'accepted, delivered, failed (temporary and permanent), complained, unsubscribed.' },
42
+ { provider: 'smtp2go', channel: 'email', verification: 'token', secret: null, events: 'processed, delivered, bounce, reject, spam, unsubscribe.' },
43
+ { provider: 'postmark', channel: 'email', verification: 'token', secret: null, events: 'Delivery, Bounce, SpamComplaint, SubscriptionChange. Use HTTP basic auth with the token as the password.' },
44
+ { provider: 'sendgrid', channel: 'email', verification: 'signature_or_token', secret: 'The public key of your SendGrid signed event webhook (base64).', events: 'processed, delivered, bounce, blocked, dropped, spamreport, unsubscribe.' },
45
+ { provider: 'resend', channel: 'email', verification: 'signature', secret: 'The signing secret (whsec_...) of your Resend webhook. Managed email on Resend registers its own and needs none.', events: 'email.sent, email.delivered, email.bounced, email.complained, email.failed.' },
46
+ { provider: 'twilio', channel: 'sms', verification: 'signature', secret: null, events: 'Message status callbacks. Verified with the auth token of your Twilio SMS provider.' },
47
+ { provider: 'vonage', channel: 'sms', verification: 'signature_or_token', secret: 'Your Vonage signature secret, as `sha256:<secret>` (or md5hash, md5, sha1, sha512 as the prefix).', events: 'SMS delivery receipts.' },
48
+ { provider: 'messagebird', channel: 'sms', verification: 'signature_or_token', secret: 'Your MessageBird request-signing key.', events: 'SMS status reports.' },
38
49
  ];
50
+ /** Signature-only providers: with no stored secret the sign helper uses the test secret, so a callback can be built before anything is stored. */
51
+ const SIGNED_ONLY = new Set(['mailgun', 'resend', 'twilio']);
52
+ const GET_RECEIVERS = ['vonage', 'messagebird'];
53
+ const EMAIL_TRANSPORTS = ['resend', 'ses', 'smtp', 'brevo', 'mailgun', 'smtp2go', 'hermetica', 'webhook'];
54
+ const SMS_TRANSPORTS = ['twilio', 'vonage', 'messagebird', 'plivo', 'telnyx', 'sinch', 'infobip', 'clicksend', 'bandwidth', 'aws_sns', 'webhook'];
55
+ const PUSH_TRANSPORTS = ['apns_fcm', 'expo'];
56
+ const PROVIDER_TRANSPORTS = { email: EMAIL_TRANSPORTS, sms: SMS_TRANSPORTS, push: PUSH_TRANSPORTS };
57
+ /** shortcut: one write-only key list for every transport and a required-key table for the common ones, where the real API validates each transport's full schema; widen when a transport's own keys matter. */
58
+ const SECRET_KEYS = new Set(['api_key', 'api_secret', 'private_key', 'access_key', 'api_token', 'auth_token', 'secret_access_key', 'access_key_id', 'session_token', 'password', 'signing_secret', 'apns_auth_key', 'fcm_service_account', 'secret', 'token']);
59
+ const REQUIRED_KEYS = { resend: ['api_key'], brevo: ['api_key'], mailgun: ['api_key', 'domain'], smtp2go: ['api_key'], hermetica: ['api_key'], ses: ['region', 'access_key_id', 'secret_access_key'], smtp: ['host'], webhook: ['url', 'signing_secret'], twilio: ['account_sid', 'auth_token'], vonage: ['api_key', 'api_secret'], messagebird: ['access_key'], plivo: ['auth_id', 'auth_token'], telnyx: ['api_key'], sinch: ['service_plan_id', 'api_token'], infobip: ['base_url', 'api_key'], clicksend: ['username', 'api_key'] };
60
+ const RANK = { queued: 0, sent: 1, delivered: 2, failed: 3, bounced: 4, complained: 5, suppressed: 6, blocked: 6 };
61
+ const EVENT_NAMES = {
62
+ delivered: (c) => (c === 'email' ? 'email.delivered' : 'sms.delivered'),
63
+ bounced: (c) => (c === 'email' ? 'email.bounced' : null),
64
+ complained: (c) => (c === 'email' ? 'email.complained' : null),
65
+ failed: (c) => (c === 'email' ? 'email.failed' : 'sms.failed'),
66
+ sent: () => null,
67
+ };
68
+ const TEST_CHANNELS = ['email', 'sms', 'push', 'whatsapp', 'voice'];
69
+ const RAW_RECEIVER = /^\/v1\/webhooks\/[a-z0-9]+\/[^/]+\/?$/;
70
+ const rawBodies = new WeakMap();
39
71
  const CALLING_CODES = { '1': 'US', '44': 'GB', '49': 'DE', '33': 'FR', '34': 'ES', '39': 'IT', '31': 'NL', '61': 'AU', '91': 'IN', '81': 'JP', '55': 'BR', '52': 'MX', '353': 'IE', '46': 'SE', '47': 'NO', '45': 'DK', '41': 'CH', '43': 'AT', '32': 'BE', '48': 'PL', '351': 'PT', '65': 'SG', '64': 'NZ', '27': 'ZA', '234': 'NG', '971': 'AE' };
40
72
  const E164 = /^\+[1-9]\d{6,14}$/;
41
73
  const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -46,10 +78,15 @@ const st = (0, support_1.engineState)((e) => ({
46
78
  suppressions: new store_1.Table('message_suppressions', e.clock.now, 'msup'),
47
79
  fallbacks: new store_1.Table('messaging_fallbacks', e.clock.now, 'mfb'),
48
80
  routes: new Map(),
49
- secrets: new Map(),
81
+ providers: new store_1.Table('messaging_providers_config', e.clock.now, 'mprov'),
82
+ receivers: new Map(),
83
+ seen: new Set(),
84
+ testSends: new Map(),
85
+ requireProviders: false,
50
86
  settings: { auto_suppress: true, failover_enabled: true, ledger_retention_days: 90, updated_at: null },
51
87
  faults: [],
52
88
  }));
89
+ exports.keyStore = (0, support_1.engineState)(() => (0, messaging_callbacks_1.testKeys)());
53
90
  const mask = (channel, to) => {
54
91
  if (channel === 'email' || to.includes('@')) {
55
92
  const [l = '', d = ''] = to.split('@');
@@ -68,20 +105,46 @@ const countryOf = (to) => {
68
105
  }
69
106
  return null;
70
107
  };
71
- function takeFault(s, providerId, channel) {
72
- const f = s.faults.find((x) => x.provider_id === providerId && (!x.channel || x.channel === channel) && x.times > 0);
108
+ function takeFault(s, providerId, transport, channel) {
109
+ const f = s.faults.find((x) => (!x.provider_id || x.provider_id === providerId) && (!x.provider || x.provider === transport) && (!x.channel || x.channel === channel) && x.times > 0);
73
110
  if (!f)
74
111
  return false;
75
112
  f.times--;
76
113
  return true;
77
114
  }
78
- /** Install suppression, failover and the ledger on the engine's message sink. Called once by the engine. */
115
+ const PHONE_CHANNELS = ['whatsapp', 'voice'];
116
+ const providerChannel = (channel) => (PHONE_CHANNELS.includes(channel) ? 'sms' : channel);
117
+ const activeFallbacks = (s, channel) => s.fallbacks.all().filter((f) => f.channel === channel && f.status === 'active').sort((a, b) => a.position - b.position);
118
+ /** The providers a message on `channel` is tried through, in order: the channel's own provider, then its fallbacks. WhatsApp and voice use the SMS provider's Twilio account only. */
119
+ function slotsFor(s, channel) {
120
+ const base = providerChannel(channel);
121
+ const cfg = s.providers.find((r) => r.channel === base);
122
+ if (!cfg && s.requireProviders)
123
+ return [];
124
+ const all = [{ id: 'primary', transport: cfg ? cfg.transport : 'emulator' }, ...activeFallbacks(s, base).map((f) => ({ id: f.id, transport: f.transport }))];
125
+ return PHONE_CHANNELS.includes(channel) && cfg ? all.filter((p) => p.transport === 'twilio') : all;
126
+ }
127
+ /** Install suppression, failover and the ledger on the engine's message sink, and the raw-body hook of the provider callback routes. Called once by the engine. */
79
128
  function installMessaging(e) {
129
+ // Callback signatures cover the exact bytes, and some providers post arrays or forms: hand those routes the raw body.
130
+ const handle = e.handle.bind(e);
131
+ e.handle = async (request) => {
132
+ if (request.method !== 'POST' || !RAW_RECEIVER.test(new URL(request.url).pathname))
133
+ return handle(request);
134
+ const raw = await request.text();
135
+ const headers = new Headers(request.headers);
136
+ const contentType = headers.get('content-type') ?? '';
137
+ headers.set('content-type', 'application/json');
138
+ const next = new Request(request.url, { method: 'POST', headers, body: '{}' });
139
+ rawBodies.set(next, { raw, contentType });
140
+ return handle(next);
141
+ };
80
142
  e.messages.route = (m) => {
81
143
  const s = st(e);
82
144
  const now = e.clock.now();
83
145
  const channel = CHANNELS.includes(m.channel) ? m.channel : 'email';
84
146
  const to = normalAddress(channel, m.to);
147
+ let order = slotsFor(s, channel);
85
148
  const row = s.ledger.insert({
86
149
  channel,
87
150
  template: typeof m.meta?.template === 'string' ? m.meta.template : 'custom',
@@ -89,7 +152,7 @@ function installMessaging(e) {
89
152
  country: channel === 'sms' ? countryOf(to) : null,
90
153
  status: 'queued',
91
154
  status_detail: null,
92
- provider: null,
155
+ provider: order[0]?.transport ?? null,
93
156
  provider_message_id: null,
94
157
  attempts: [],
95
158
  timeline: [{ status: 'queued', at: now }],
@@ -102,20 +165,24 @@ function installMessaging(e) {
102
165
  row.timeline.push({ status: 'suppressed', at: now, detail: hit.reason });
103
166
  return { deliver: false };
104
167
  }
105
- // Primary first (or the country's route for SMS), then active fallbacks in order, when failover is on.
106
- const fb = s.fallbacks.all().filter((f) => f.channel === channel && f.status === 'active').sort((a, b) => a.position - b.position);
107
- let order = [{ id: 'primary', transport: 'emulator' }, ...fb.map((f) => ({ id: f.id, transport: f.transport }))];
168
+ // The country's route for SMS goes first, then the rest in order; with failover off only the first is tried.
108
169
  const routed = row.country ? s.routes.get(row.country)?.provider_id : undefined;
109
170
  if (routed)
110
171
  order = [...order.filter((p) => p.id === routed), ...order.filter((p) => p.id !== routed)];
111
172
  if (!s.settings.failover_enabled)
112
173
  order = order.slice(0, 1);
174
+ if (!order.length) {
175
+ const detail = `no ${channel} provider is configured`;
176
+ Object.assign(row, { status: 'failed', status_detail: detail, updated_at: now });
177
+ row.timeline.push({ status: 'failed', at: now, detail });
178
+ return { deliver: false };
179
+ }
113
180
  for (const p of order) {
114
- if (takeFault(s, p.id, channel)) {
115
- row.attempts.push({ provider: p.transport, provider_id: p.id, status: 'failed', error: 'injected provider failure', at: e.clock.now() });
181
+ if (takeFault(s, p.id, p.transport, channel)) {
182
+ row.attempts.push({ provider: p.transport, provider_id: p.id, channel, status: 'failed', error: 'injected provider failure', at: e.clock.now() });
116
183
  continue;
117
184
  }
118
- row.attempts.push({ provider: p.transport, provider_id: p.id, status: 'sent', at: e.clock.now() });
185
+ row.attempts.push({ provider: p.transport, provider_id: p.id, channel, status: 'sent', at: e.clock.now() });
119
186
  return {
120
187
  deliver: true,
121
188
  done: () => {
@@ -171,6 +238,155 @@ const channelParam = (v) => {
171
238
  return v;
172
239
  };
173
240
  const failoverOrder = (e, channel) => ['primary', ...st(e).fallbacks.all().filter((f) => f.channel === channel && f.status === 'active').sort((a, b) => a.position - b.position).map((f) => f.id)];
241
+ const providerView = (r) => r
242
+ ? {
243
+ object: 'messaging_provider',
244
+ configured: true,
245
+ id: r.id,
246
+ channel: r.channel,
247
+ transport: r.transport,
248
+ mode: r.mode,
249
+ from_address: r.from_address,
250
+ status: r.status,
251
+ config: Object.fromEntries(Object.entries(r.config).filter(([k]) => !SECRET_KEYS.has(k))),
252
+ secrets_set: Object.fromEntries(Object.entries(r.config).filter(([k]) => SECRET_KEYS.has(k)).map(([k]) => [k, true])),
253
+ updated_at: r.updated_at,
254
+ }
255
+ : null;
256
+ const receiverRow = (e, provider) => {
257
+ const m = st(e).receivers;
258
+ if (!m.has(provider))
259
+ m.set(provider, { secret: null, last_event_at: null, last_error: null });
260
+ return m.get(provider);
261
+ };
262
+ async function receiverView(e, r, reveal) {
263
+ const row = st(e).receivers.get(r.provider);
264
+ const secretSet = Boolean(row?.secret);
265
+ const usesToken = r.verification === 'token' || (r.verification === 'signature_or_token' && !secretSet);
266
+ const base = `${e.issuer}/v1/webhooks/${r.provider}/${e.instanceId}`;
267
+ const token = usesToken && reveal ? await (0, messaging_callbacks_1.receiverToken)(e.instanceId, r.provider) : undefined;
268
+ return {
269
+ object: 'message_receiver',
270
+ provider: r.provider,
271
+ channel: r.channel,
272
+ verification: r.verification,
273
+ url: token && r.provider !== 'brevo' && r.provider !== 'postmark' ? `${base}?t=${token}` : base,
274
+ token_required: usesToken,
275
+ ...(token ? { token } : {}),
276
+ secret_set: secretSet,
277
+ secret_help: r.secret,
278
+ events: r.events,
279
+ last_event_at: row?.last_event_at ?? null,
280
+ last_error: row?.last_error ?? null,
281
+ };
282
+ }
283
+ async function checkSecret(provider, secret) {
284
+ const v = secret.trim();
285
+ if (!v || v.length > 4096)
286
+ throw new errors_1.EmulatorError('VALIDATION_FAILED', 'secret must be a non-empty string of at most 4096 characters.', 'secret');
287
+ if (provider === 'sendgrid') {
288
+ try {
289
+ await crypto.subtle.importKey('spki', Uint8Array.from(atob(v.replace(/\s+/g, '')), (c) => c.charCodeAt(0)), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
290
+ }
291
+ catch {
292
+ throw new errors_1.EmulatorError('VALIDATION_FAILED', 'secret must be the base64 public key of your SendGrid signed event webhook.', 'secret');
293
+ }
294
+ }
295
+ if (provider === 'ses' && !v.split(',').every((t) => /^arn:aws[a-z-]*:sns:[a-z0-9-]+:\d{12}:[A-Za-z0-9_-]{1,256}$/.test(t.trim())))
296
+ throw new errors_1.EmulatorError('VALIDATION_FAILED', 'secret must be a comma-separated list of SNS topic ARNs.', 'secret');
297
+ if (provider === 'resend' && !/^whsec_/.test(v))
298
+ throw new errors_1.EmulatorError('VALIDATION_FAILED', 'secret must be the Resend webhook signing secret (whsec_...).', 'secret');
299
+ return v;
300
+ }
301
+ /** What a verified callback does, whichever provider sent it: moves the ledger row (a later report never moves it back), suppresses on a hard bounce or complaint, emits the delivery event once per real change. */
302
+ function applyEvents(e, events) {
303
+ const s = st(e);
304
+ for (const ev of events) {
305
+ const soft = ev.status === 'bounced' && ev.bounceType === 'soft';
306
+ const status = soft ? 'failed' : ev.status;
307
+ const detail = soft ? `soft bounce${ev.detail ? `: ${ev.detail}` : ''}` : ev.detail;
308
+ let row;
309
+ for (const id of ev.messageIds) {
310
+ row = s.ledger.find((r) => r.provider === ev.provider && r.provider_message_id === id);
311
+ if (row)
312
+ break;
313
+ }
314
+ let changed = true;
315
+ if (row) {
316
+ const last = row.timeline[row.timeline.length - 1];
317
+ changed = !(last && last.status === status && last.detail === detail?.slice(0, 300));
318
+ if (changed)
319
+ row.timeline.push({ status, at: ev.at, ...(detail ? { detail: detail.slice(0, 300) } : {}) });
320
+ if ((RANK[status] ?? 0) >= (RANK[row.status] ?? 0))
321
+ Object.assign(row, { status, status_detail: detail ?? null });
322
+ row.updated_at = e.clock.now();
323
+ }
324
+ if (ev.suppress && ev.recipient && s.settings.auto_suppress) {
325
+ const address = normalAddress(ev.channel, ev.recipient);
326
+ if (!s.suppressions.find((x) => x.address === address && (ev.channel !== 'email' || x.channel === 'email')))
327
+ addSuppression(e, ev.channel, address, ev.suppress, ev.provider, ev.detail ?? null, row?.id ?? null);
328
+ }
329
+ const name = changed ? EVENT_NAMES[ev.status]?.(ev.channel) : null;
330
+ if (name)
331
+ e.webhooks.emit(name, { object: 'message_delivery', message_id: row?.id ?? ev.messageIds[0] ?? '', channel: ev.channel, recipient: ev.recipient ?? null, ...(ev.status === 'bounced' && ev.bounceType ? { bounce_type: ev.bounceType } : {}), ...(ev.status === 'failed' || ev.status === 'bounced' ? { reason: (detail ?? ev.status).slice(0, 120) } : {}), provider: ev.provider, provider_message_id: ev.messageIds[0] ?? null, ledger_id: row?.id ?? null, at: ev.at });
332
+ }
333
+ }
334
+ /** A provider callback: verified with the provider's own scheme first; anything that does not verify is a 401 that changes nothing, the same answer whether the instance exists or not. */
335
+ async function receive(c, provider) {
336
+ const e = c.engine;
337
+ const s = st(e);
338
+ const refuse = (message = 'Invalid webhook signature.') => new errors_1.EmulatorError('UNAUTHENTICATED', message);
339
+ if (c.params.instanceId !== e.instanceId)
340
+ throw refuse(provider === 'brevo' ? 'Invalid webhook token.' : undefined);
341
+ const stash = rawBodies.get(c.request);
342
+ const row = receiverRow(e, provider);
343
+ const u = new URL(c.request.url);
344
+ const path = `${u.pathname}${u.search}`;
345
+ const twilioTokens = [s.providers.find((r) => r.channel === 'sms' && r.transport === 'twilio')?.config.auth_token, ...activeFallbacks(s, 'sms').filter((f) => f.transport === 'twilio').map((f) => f.config?.auth_token)].filter((t) => typeof t === 'string' && !!t);
346
+ const out = await (0, messaging_callbacks_1.verifyCallback)({
347
+ provider,
348
+ raw: stash?.raw ?? '',
349
+ contentType: stash?.contentType ?? c.request.headers.get('content-type') ?? '',
350
+ headers: c.request.headers,
351
+ query: Object.fromEntries(c.query),
352
+ urls: [`${e.issuer}${path}`, `${u.origin}${path}`, `https://${u.host}${path}`],
353
+ secret: row.secret,
354
+ token: await (0, messaging_callbacks_1.receiverToken)(e.instanceId, provider),
355
+ twilioTokens,
356
+ snsCertificate: async (url) => (0, emu4_sns_1.snsCertificateFor)(e, url, (await (0, exports.keyStore)(e).rsa()).certPem),
357
+ now: Math.floor(e.clock.now() / 1000),
358
+ });
359
+ if (!out.ok) {
360
+ if (out.malformed)
361
+ return { status: 400, body: { errors: [{ code: 'VALIDATION_FAILED', message: out.message ?? 'Request body is not valid.' }] } };
362
+ row.last_error = out.why;
363
+ throw refuse(out.message);
364
+ }
365
+ // A provider redelivers what it thinks was not acknowledged: an event id already applied changes nothing.
366
+ const key = `${provider}:${out.id}`;
367
+ if (!s.seen.has(key)) {
368
+ s.seen.add(key);
369
+ applyEvents(e, out.events);
370
+ row.last_event_at = e.clock.now();
371
+ row.last_error = null;
372
+ }
373
+ // The real receiver visits the SNS confirmation URL; the emulator makes no request and records it instead (GET /__emulator/messaging/sns).
374
+ if (out.subscribeUrl)
375
+ (0, emu4_sns_1.snsState)(e).confirmations.push({ subscribe_url: out.subscribeUrl, at: e.clock.now() });
376
+ return { body: { received: true, ...(out.confirmed ? { confirmed: true } : {}) } };
377
+ }
378
+ const SCHEMES = {
379
+ ses: 'Amazon SNS message signature (the emulator signs with its own key) plus the URL token',
380
+ brevo: 'bearer token',
381
+ mailgun: 'HMAC-SHA256 of timestamp+token in the body signature object, with the webhook signing key',
382
+ smtp2go: 'URL token (bearer, basic-auth password or ?t=)',
383
+ postmark: 'URL token (basic-auth password, bearer or ?t=)',
384
+ sendgrid: 'ECDSA P-256 signature over timestamp+body (X-Twilio-Email-Event-Webhook-Signature) with the stored public key; the URL token when no key is stored',
385
+ resend: 'Svix signature (svix-id, svix-timestamp, svix-signature) with the whsec_ secret, 5 minute tolerance',
386
+ twilio: 'X-Twilio-Signature: base64 HMAC-SHA1 over the URL and the sorted form fields, with the auth token of the SMS provider',
387
+ vonage: 'sig over the sorted parameters with the signature secret; the URL token when no secret is stored',
388
+ messagebird: 'MessageBird-Signature: base64 HMAC-SHA256 over timestamp, query and body hash, with the signing key; the URL token when no key is stored',
389
+ };
174
390
  function messagingHandlers() {
175
391
  const list = (e, channel) => ({ object: 'list', data: st(e).fallbacks.all().filter((f) => f.channel === channel).sort((a, b) => a.position - b.position).map(fallbackView), has_more: false, failover_order: failoverOrder(e, channel) });
176
392
  const saveFallback = (e, channel, b, existing) => {
@@ -408,47 +624,166 @@ function messagingHandlers() {
408
624
  return { body: { object: 'messaging_settings', ...s } };
409
625
  },
410
626
  },
411
- // ── status receivers ──
627
+ // ── per-channel providers ──
412
628
  {
413
629
  method: 'GET',
414
- pattern: '/v1/message_receivers',
630
+ pattern: '/v1/messaging_providers',
415
631
  scope: 'messaging:read',
416
- handler: (c) => ({
417
- body: {
418
- object: 'list',
419
- has_more: false,
420
- data: RECEIVERS.map((r) => ({ object: 'message_receiver', provider: r.provider, channel: r.channel, verification: r.verification, url: `${c.engine.issuer}/v1/message_receivers/${r.provider}/${c.engine.instanceId}`, token_required: r.verification === 'token', secret_set: st(c.engine).secrets.has(r.provider), secret_help: r.secret, events: r.events, last_event_at: st(c.engine).secrets.get(r.provider) ?? null, last_error: null })),
421
- },
422
- }),
632
+ handler: (c) => {
633
+ const view = (ch) => providerView(st(c.engine).providers.find((r) => r.channel === ch));
634
+ return { body: { object: 'messaging_providers', email: view('email'), sms: view('sms'), push: view('push'), managed_email: null, development_email: null } };
635
+ },
423
636
  },
424
637
  {
638
+ method: 'GET',
639
+ pattern: '/v1/messaging_providers/:channel',
640
+ scope: 'messaging:read',
641
+ handler: (c) => {
642
+ const channel = c.params.channel;
643
+ if (!PROVIDER_TRANSPORTS[channel])
644
+ throw (0, errors_1.notFound)('Messaging channel (use email, sms or push)');
645
+ const row = st(c.engine).providers.find((r) => r.channel === channel);
646
+ return { body: row ? providerView(row) : { object: 'messaging_provider', configured: false, channel, message: `No ${channel} provider is configured. PUT /v1/messaging_providers/${channel} to add one.` } };
647
+ },
648
+ },
649
+ ...['email', 'sms', 'push'].map((channel) => ({
425
650
  method: 'PUT',
426
- pattern: '/v1/message_receivers/:provider/secret',
651
+ pattern: `/v1/messaging_providers/${channel}`,
427
652
  scope: 'messaging:write',
428
653
  handler: (c) => {
429
- const r = RECEIVERS.find((x) => x.provider === c.params.provider);
430
- if (!r)
431
- throw (0, errors_1.notFound)(`Provider (use one of: ${RECEIVERS.map((x) => x.provider).join(', ')})`);
432
- if (r.secret === null)
433
- throw new errors_1.EmulatorError('VALIDATION_FAILED', `${r.provider} needs no verification secret.`, 'secret');
434
- if (typeof c.body.secret !== 'string' || !c.body.secret.trim() || c.body.secret.length > 4096)
435
- throw new errors_1.EmulatorError('VALIDATION_FAILED', 'secret must be a non-empty string of at most 4096 characters.', 'secret');
436
- if (r.provider === 'resend' && !c.body.secret.startsWith('whsec_'))
437
- throw new errors_1.EmulatorError('VALIDATION_FAILED', 'secret must be the Resend webhook signing secret (whsec_...).', 'secret');
438
- st(c.engine).secrets.set(r.provider, c.engine.clock.now());
439
- return { body: { object: 'message_receiver', provider: r.provider, secret_set: true } };
654
+ const e = c.engine;
655
+ const s = st(e);
656
+ const allowed = PROVIDER_TRANSPORTS[channel];
657
+ const transport = c.body.transport === 'hermeticor' ? 'hermetica' : c.body.transport;
658
+ if (typeof transport !== 'string' || !allowed.includes(transport))
659
+ throw new errors_1.EmulatorError('VALIDATION_FAILED', `transport must be one of: ${allowed.join(', ')}.`, 'transport');
660
+ if (c.body.mode !== undefined && (channel !== 'email' || (c.body.mode !== 'standard' && c.body.mode !== 'development')))
661
+ throw new errors_1.EmulatorError('VALIDATION_FAILED', channel === 'email' ? 'mode must be "standard" or "development".' : 'mode applies to the email provider only.', 'mode');
662
+ if (c.body.config !== undefined && (!c.body.config || typeof c.body.config !== 'object' || Array.isArray(c.body.config)))
663
+ throw new errors_1.EmulatorError('VALIDATION_FAILED', 'config must be an object of provider settings.', 'config');
664
+ const existing = s.providers.find((r) => r.channel === channel);
665
+ // A secret the body leaves out (or sends empty) keeps the stored one, when the transport is unchanged.
666
+ const merged = { ...(existing && existing.transport === transport ? existing.config : {}) };
667
+ for (const [k, v] of Object.entries((c.body.config ?? {})))
668
+ if (v !== '' && v !== null && v !== undefined)
669
+ merged[k] = String(v);
670
+ const fromAddress = typeof c.body.from_address === 'string' && c.body.from_address.trim() ? c.body.from_address.trim() : null;
671
+ for (const k of REQUIRED_KEYS[transport] ?? [])
672
+ if (!merged[k])
673
+ throw new errors_1.EmulatorError('VALIDATION_FAILED', `config.${k} is required for ${transport}.`, `config.${k}`);
674
+ if (transport === 'twilio' && !merged.messaging_service_sid && !merged.from && !fromAddress)
675
+ throw new errors_1.EmulatorError('VALIDATION_FAILED', 'twilio needs a sender: set from_address (or config.from) or config.messaging_service_sid.', 'config.messaging_service_sid');
676
+ if (transport === 'apns_fcm' && !merged.apns_auth_key && !merged.fcm_service_account)
677
+ throw new errors_1.EmulatorError('VALIDATION_FAILED', 'apns_fcm needs config.apns_auth_key (with key id, team id and topic) or config.fcm_service_account (with project id).', 'config.apns_auth_key');
678
+ const mode = channel === 'email' ? (c.body.mode ?? (existing && existing.transport === transport ? existing.mode : 'standard')) : 'standard';
679
+ const fields = { channel, transport, from_address: fromAddress, config: merged, mode, status: 'active', updated_at: e.clock.now() };
680
+ return { body: providerView(existing ? Object.assign(existing, fields) : s.providers.insert(fields)) };
440
681
  },
441
- },
682
+ })),
442
683
  {
443
684
  method: 'DELETE',
444
- pattern: '/v1/message_receivers/:provider/secret',
685
+ pattern: '/v1/messaging_providers/:channel',
445
686
  scope: 'messaging:write',
446
687
  handler: (c) => {
447
- if (!st(c.engine).secrets.delete(c.params.provider ?? ''))
448
- throw (0, errors_1.notFound)('Verification secret for that provider');
449
- return { body: { object: 'message_receiver', provider: c.params.provider, deleted: true } };
688
+ const channel = c.params.channel;
689
+ if (!PROVIDER_TRANSPORTS[channel])
690
+ throw new errors_1.EmulatorError('VALIDATION_FAILED', 'channel must be email, sms or push.', 'channel');
691
+ const row = st(c.engine).providers.find((r) => r.channel === channel);
692
+ if (!row)
693
+ throw (0, errors_1.notFound)('A provider for that channel');
694
+ st(c.engine).providers.delete(row.id);
695
+ return { body: { object: 'messaging_provider', channel, deleted: true } };
450
696
  },
451
697
  },
698
+ {
699
+ method: 'POST',
700
+ pattern: '/v1/messaging_providers/:channel/test',
701
+ scope: 'messaging:write',
702
+ handler: (c) => {
703
+ const e = c.engine;
704
+ const s = st(e);
705
+ const channel = c.params.channel;
706
+ if (!TEST_CHANNELS.includes(channel))
707
+ throw new errors_1.EmulatorError('VALIDATION_FAILED', 'channel must be email, sms, push, whatsapp or voice.', 'channel');
708
+ const to = typeof c.body.to === 'string' ? c.body.to.trim() : '';
709
+ if (!to)
710
+ throw new errors_1.EmulatorError('VALIDATION_FAILED', 'A destination is required.', 'to');
711
+ // Throttled per key, like the real route: one test every 15 seconds, ten an hour.
712
+ const now = e.clock.now();
713
+ const hits = (s.testSends.get(c.key?.key ?? '') ?? []).filter((t) => now - t < 3_600_000);
714
+ if (hits.some((t) => now - t < 15_000))
715
+ throw new errors_1.EmulatorError('RATE_LIMITED', 'Wait a moment before sending another test message.');
716
+ if (hits.length >= 10)
717
+ throw new errors_1.EmulatorError('RATE_LIMITED', 'You have sent a lot of test messages in the last hour. Try again shortly.');
718
+ if (channel === 'push' ? to.length > 4096 || /\s/.test(to) : channel === 'email' ? !EMAIL.test(to) : !E164.test(to))
719
+ throw new errors_1.EmulatorError('VALIDATION_FAILED', channel === 'push' ? 'Paste the device token to send to.' : channel === 'email' ? 'Enter a valid email address.' : 'Enter a phone number in E.164 form, e.g. +15550001111.', 'to');
720
+ // A test proves YOUR provider: with none configured for the channel it refuses, whatever the require_providers switch says.
721
+ const base = providerChannel(channel);
722
+ const cfg = s.providers.find((r) => r.channel === base);
723
+ if (!cfg || (PHONE_CHANNELS.includes(channel) && !slotsFor(s, channel).length))
724
+ throw new errors_1.EmulatorError('VALIDATION_FAILED', `No ${channel} provider is configured for this instance. Add one before sending a test.`);
725
+ s.testSends.set(c.key?.key ?? '', [...hits, now]);
726
+ const address = normalAddress(channel, to);
727
+ e.messages.send({ channel, to: address, subject: channel === 'email' ? 'Atlas provider test' : undefined, body: 'This is a test message from Atlas. No action is needed.', meta: { template: 'provider_test' } });
728
+ const row = s.ledger.find((r) => r.template === 'provider_test' && r.channel === channel && r.recipient === address);
729
+ const shown = channel === 'push' ? `${to.slice(0, 6)}...${to.slice(-4)}` : to;
730
+ if (row.status === 'suppressed')
731
+ throw new errors_1.EmulatorError('CONFLICT', 'That recipient is on the suppression list.');
732
+ if (row.status === 'failed')
733
+ return { status: 502, body: { errors: [{ code: 'INTERNAL_ERROR', message: 'The provider rejected the test message.', meta: { ok: false, accepted: false, failure: 'provider_unreachable', provider: row.provider, provider_message_id: null, provider_status: null, provider_reason: row.status_detail } }] } };
734
+ return { body: { object: 'messaging_test', channel, sent_to: shown, ok: true, accepted: true, provider: row.provider, provider_message_id: row.provider_message_id } };
735
+ },
736
+ },
737
+ // ── status receivers: where each provider reports delivery ──
738
+ {
739
+ method: 'GET',
740
+ pattern: '/v1/message_receivers',
741
+ scope: 'messaging:read',
742
+ handler: async (c) => {
743
+ const k = c.key?.scopes;
744
+ const reveal = !k || k.includes('*') || k.includes('messaging:write');
745
+ return { body: { object: 'list', has_more: false, data: await Promise.all(RECEIVERS.map((r) => receiverView(c.engine, r, reveal))) } };
746
+ },
747
+ },
748
+ ...['/v1/message_receivers/:provider', '/v1/message_receivers/:provider/secret'].flatMap((pattern) => [
749
+ {
750
+ method: 'PUT',
751
+ pattern,
752
+ scope: 'messaging:write',
753
+ handler: async (c) => {
754
+ const r = RECEIVERS.find((x) => x.provider === c.params.provider);
755
+ if (!r)
756
+ throw (0, errors_1.notFound)(`Provider (use one of: ${RECEIVERS.map((x) => x.provider).join(', ')})`);
757
+ if (r.secret === null)
758
+ throw new errors_1.EmulatorError('VALIDATION_FAILED', `${r.provider} needs no verification secret.`, 'secret');
759
+ if (typeof c.body.secret !== 'string')
760
+ throw new errors_1.EmulatorError('VALIDATION_FAILED', 'secret must be a string.', 'secret');
761
+ const secret = await checkSecret(r.provider, c.body.secret);
762
+ const row = receiverRow(c.engine, r.provider);
763
+ row.secret = secret;
764
+ return { body: await receiverView(c.engine, r, true) };
765
+ },
766
+ },
767
+ {
768
+ method: 'DELETE',
769
+ pattern,
770
+ scope: 'messaging:write',
771
+ handler: (c) => {
772
+ const row = st(c.engine).receivers.get(c.params.provider ?? '');
773
+ if (!row?.secret)
774
+ throw (0, errors_1.notFound)('Verification secret for that provider');
775
+ row.secret = null;
776
+ return { body: { object: 'message_receiver', provider: c.params.provider, deleted: true } };
777
+ },
778
+ },
779
+ ]),
780
+ // ── provider callbacks: public, and refused unless the provider's own signature (or URL token) verifies ──
781
+ ...RECEIVERS.flatMap((r) => ['POST', ...(GET_RECEIVERS.includes(r.provider) ? ['GET'] : [])].map((method) => ({
782
+ method,
783
+ pattern: `/v1/webhooks/${r.provider}/:instanceId`,
784
+ public: true,
785
+ handler: (c) => receive(c, r.provider),
786
+ }))),
452
787
  // ── emulator controls: make a provider fail, play a delivery callback ──
453
788
  {
454
789
  method: 'POST',
@@ -456,7 +791,13 @@ function messagingHandlers() {
456
791
  public: true,
457
792
  handler: (c) => {
458
793
  const times = (0, support_1.intIn)(c.body.times, 'times', 1, 1000, 1);
459
- st(c.engine).faults.push({ provider_id: String(c.body.provider_id ?? 'primary'), channel: typeof c.body.channel === 'string' ? c.body.channel : undefined, times });
794
+ if (c.body.channel !== undefined && !CHANNELS.includes(c.body.channel))
795
+ throw new errors_1.EmulatorError('VALIDATION_FAILED', `channel must be one of: ${CHANNELS.join(', ')}.`, 'channel');
796
+ if (c.body.provider !== undefined && typeof c.body.provider !== 'string')
797
+ throw new errors_1.EmulatorError('VALIDATION_FAILED', 'provider must be a transport name such as resend or twilio.', 'provider');
798
+ // Name a slot (provider_id: primary or a fallback id), a transport (provider: resend, twilio ...), a channel, or any mix; naming neither slot nor transport means the primary.
799
+ const slot = c.body.provider_id !== undefined ? String(c.body.provider_id) : c.body.provider === undefined ? 'primary' : undefined;
800
+ st(c.engine).faults.push({ provider_id: slot, provider: c.body.provider, channel: c.body.channel, times });
460
801
  return { status: 201, body: { ok: true } };
461
802
  },
462
803
  },
@@ -481,6 +822,72 @@ function messagingHandlers() {
481
822
  return { body: { ok: true, status } };
482
823
  },
483
824
  },
825
+ {
826
+ method: 'POST',
827
+ pattern: '/__emulator/messaging/require_providers',
828
+ public: true,
829
+ handler: (c) => {
830
+ if (typeof c.body.enabled !== 'boolean')
831
+ throw new errors_1.EmulatorError('VALIDATION_FAILED', 'enabled must be true or false.', 'enabled');
832
+ st(c.engine).requireProviders = c.body.enabled;
833
+ return { body: { ok: true, require_providers: c.body.enabled } };
834
+ },
835
+ },
836
+ {
837
+ method: 'GET',
838
+ pattern: '/__emulator/messaging/signing_keys',
839
+ public: true,
840
+ handler: async (c) => {
841
+ const e = c.engine;
842
+ const keys = (0, exports.keyStore)(e);
843
+ const data = await Promise.all(RECEIVERS.map(async (r) => ({
844
+ object: 'messaging_signing_key',
845
+ provider: r.provider,
846
+ channel: r.channel,
847
+ verification: r.verification,
848
+ scheme: SCHEMES[r.provider],
849
+ // What to store with PUT /v1/message_receivers/{provider} (SendGrid: the public key; Twilio: put it on the SMS provider as config.auth_token).
850
+ secret: r.provider === 'sendgrid' ? (await keys.ec()).publicB64 : r.secret === null && r.provider !== 'twilio' ? null : r.provider === 'ses' ? null : await (0, messaging_callbacks_1.testSecret)(e.instanceId, r.provider),
851
+ token: r.verification === 'signature' ? null : await (0, messaging_callbacks_1.receiverToken)(e.instanceId, r.provider),
852
+ ...(r.provider === 'ses' ? { signing_cert_url: messaging_callbacks_1.SNS_CERT_URL, public_key: (await keys.rsa()).publicB64, certificate_pem: (await keys.rsa()).certPem } : {}),
853
+ url: `${e.issuer}/v1/webhooks/${r.provider}/${e.instanceId}`,
854
+ })));
855
+ return { body: { object: 'list', data, has_more: false } };
856
+ },
857
+ },
858
+ {
859
+ method: 'POST',
860
+ pattern: '/__emulator/messaging/sign',
861
+ public: true,
862
+ handler: async (c) => {
863
+ const e = c.engine;
864
+ const s = st(e);
865
+ const provider = (0, support_1.oneOf)(c.body, 'provider', RECEIVERS.map((r) => r.provider));
866
+ const event = (0, support_1.oneOf)(c.body, 'event', ['sent', 'delivered', 'bounced', 'complained', 'failed'], 'delivered');
867
+ const bounce = (0, support_1.oneOf)(c.body, 'bounce', ['hard', 'soft'], 'hard');
868
+ const tamper = c.body.tamper === undefined ? undefined : (0, support_1.oneOf)(c.body, 'tamper', ['signature', 'stale']);
869
+ const row = c.body.message_id === undefined ? undefined : s.ledger.get(String(c.body.message_id));
870
+ if (c.body.message_id !== undefined && !row)
871
+ throw (0, errors_1.notFound)('Message');
872
+ const messageId = typeof c.body.provider_message_id === 'string' && c.body.provider_message_id ? c.body.provider_message_id : row?.provider_message_id;
873
+ const recipient = typeof c.body.recipient === 'string' && c.body.recipient ? c.body.recipient : row?.recipient;
874
+ if (!messageId || !recipient)
875
+ throw new errors_1.EmulatorError('VALIDATION_FAILED', 'Send message_id of a delivered message, or provider_message_id and recipient.', 'message_id');
876
+ const stored = s.receivers.get(provider)?.secret ?? null;
877
+ const twilioToken = s.providers.find((r) => r.channel === 'sms' && r.transport === 'twilio')?.config.auth_token;
878
+ const secret = provider === 'twilio' ? (twilioToken ?? (await (0, messaging_callbacks_1.testSecret)(e.instanceId, provider))) : (stored ?? (SIGNED_ONLY.has(provider) ? await (0, messaging_callbacks_1.testSecret)(e.instanceId, provider) : null));
879
+ const url = `${e.issuer}/v1/webhooks/${provider}/${e.instanceId}`;
880
+ let prepared;
881
+ try {
882
+ prepared = await (0, messaging_callbacks_1.prepareCallback)({ provider, event, bounce, messageId, recipient, detail: typeof c.body.detail === 'string' ? c.body.detail : undefined, secret, token: await (0, messaging_callbacks_1.receiverToken)(e.instanceId, provider), url, now: Math.floor(e.clock.now() / 1000) - (tamper === 'stale' ? 3600 : 0), salt: tamper === 'signature' ? 'tampered' : '', topic: stored?.split(',')[0]?.trim() || 'arn:aws:sns:us-east-1:123456789012:atlas-emulator', keys: (0, exports.keyStore)(e) });
883
+ }
884
+ catch {
885
+ throw new errors_1.EmulatorError('VALIDATION_FAILED', `The stored ${provider} secret cannot sign a callback. Store the secret from GET /__emulator/messaging/signing_keys.`, 'provider');
886
+ }
887
+ const query = new URLSearchParams(prepared.query).toString();
888
+ return { body: { object: 'messaging_callback', provider, method: 'POST', url: query ? `${url}?${query}` : url, headers: { 'content-type': prepared.content_type, ...prepared.headers }, body: prepared.body, signed_with: secret === null && provider !== 'ses' ? 'url token' : 'signature' } };
889
+ },
890
+ },
484
891
  ];
485
892
  }
486
893
  //# sourceMappingURL=messaging.js.map