@atlasauth/emulator-core 0.1.0 → 0.3.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.
- package/dist/adopt.d.ts +50 -0
- package/dist/adopt.js +57 -0
- package/dist/adopt.js.map +1 -0
- package/dist/coverage.d.ts +15 -0
- package/dist/coverage.js +31 -0
- package/dist/coverage.js.map +1 -0
- package/dist/crypto-util.d.ts +10 -0
- package/dist/crypto-util.js +76 -0
- package/dist/crypto-util.js.map +1 -0
- package/dist/custom-html.d.ts +57 -0
- package/dist/custom-html.js +491 -0
- package/dist/custom-html.js.map +1 -0
- package/dist/engine.d.ts +34 -3
- package/dist/engine.js +57 -8
- package/dist/engine.js.map +1 -1
- package/dist/error-catalog.js +16 -0
- package/dist/error-catalog.js.map +1 -1
- package/dist/event-gate.d.ts +3 -0
- package/dist/event-gate.js +430 -0
- package/dist/event-gate.js.map +1 -0
- package/dist/generic.js +14 -2
- package/dist/generic.js.map +1 -1
- package/dist/handlers/access-signals.d.ts +48 -0
- package/dist/handlers/access-signals.js +76 -0
- package/dist/handlers/access-signals.js.map +1 -0
- package/dist/handlers/approvals.d.ts +2 -0
- package/dist/handlers/approvals.js +418 -0
- package/dist/handlers/approvals.js.map +1 -0
- package/dist/handlers/authz-data.d.ts +2 -0
- package/dist/handlers/authz-data.js +627 -0
- package/dist/handlers/authz-data.js.map +1 -0
- package/dist/handlers/authz-layers.d.ts +229 -0
- package/dist/handlers/authz-layers.js +786 -0
- package/dist/handlers/authz-layers.js.map +1 -0
- package/dist/handlers/authz-state.d.ts +80 -0
- package/dist/handlers/authz-state.js +175 -0
- package/dist/handlers/authz-state.js.map +1 -0
- package/dist/handlers/authz.d.ts +2 -0
- package/dist/handlers/authz.js +410 -0
- package/dist/handlers/authz.js.map +1 -0
- package/dist/handlers/billing.d.ts +100 -0
- package/dist/handlers/billing.js +877 -0
- package/dist/handlers/billing.js.map +1 -0
- package/dist/handlers/cedar-ext.d.ts +35 -0
- package/dist/handlers/cedar-ext.js +174 -0
- package/dist/handlers/cedar-ext.js.map +1 -0
- package/dist/handlers/cedar.d.ts +146 -0
- package/dist/handlers/cedar.js +689 -0
- package/dist/handlers/cedar.js.map +1 -0
- package/dist/handlers/connections.d.ts +5 -0
- package/dist/handlers/connections.js +561 -0
- package/dist/handlers/connections.js.map +1 -0
- package/dist/handlers/control.js +6 -1
- package/dist/handlers/control.js.map +1 -1
- package/dist/handlers/device.d.ts +47 -0
- package/dist/handlers/device.js +665 -0
- package/dist/handlers/device.js.map +1 -0
- package/dist/handlers/emu3-misc.d.ts +38 -0
- package/dist/handlers/emu3-misc.js +1056 -0
- package/dist/handlers/emu3-misc.js.map +1 -0
- package/dist/handlers/emu4-actions-access.d.ts +46 -0
- package/dist/handlers/emu4-actions-access.js +107 -0
- package/dist/handlers/emu4-actions-access.js.map +1 -0
- package/dist/handlers/emu4-actions-catalog-extra.d.ts +3 -0
- package/dist/handlers/emu4-actions-catalog-extra.js +204 -0
- package/dist/handlers/emu4-actions-catalog-extra.js.map +1 -0
- package/dist/handlers/emu4-actions-catalog.d.ts +3 -0
- package/dist/handlers/emu4-actions-catalog.js +143 -0
- package/dist/handlers/emu4-actions-catalog.js.map +1 -0
- package/dist/handlers/emu4-actions-def.d.ts +50 -0
- package/dist/handlers/emu4-actions-def.js +250 -0
- package/dist/handlers/emu4-actions-def.js.map +1 -0
- package/dist/handlers/emu4-actions-sandbox.d.ts +36 -0
- package/dist/handlers/emu4-actions-sandbox.js +318 -0
- package/dist/handlers/emu4-actions-sandbox.js.map +1 -0
- package/dist/handlers/emu4-actions-typegen.d.ts +5 -0
- package/dist/handlers/emu4-actions-typegen.js +73 -0
- package/dist/handlers/emu4-actions-typegen.js.map +1 -0
- package/dist/handlers/emu4-actions.d.ts +2 -0
- package/dist/handlers/emu4-actions.js +506 -0
- package/dist/handlers/emu4-actions.js.map +1 -0
- package/dist/handlers/emu4-authz-trust.d.ts +40 -0
- package/dist/handlers/emu4-authz-trust.js +488 -0
- package/dist/handlers/emu4-authz-trust.js.map +1 -0
- package/dist/handlers/emu4-authz.d.ts +2 -0
- package/dist/handlers/emu4-authz.js +752 -0
- package/dist/handlers/emu4-authz.js.map +1 -0
- package/dist/handlers/emu4-billing-core.d.ts +23 -0
- package/dist/handlers/emu4-billing-core.js +22 -0
- package/dist/handlers/emu4-billing-core.js.map +1 -0
- package/dist/handlers/emu4-billing-x.d.ts +2 -0
- package/dist/handlers/emu4-billing-x.js +489 -0
- package/dist/handlers/emu4-billing-x.js.map +1 -0
- package/dist/handlers/emu4-billing.d.ts +14 -0
- package/dist/handlers/emu4-billing.js +846 -0
- package/dist/handlers/emu4-billing.js.map +1 -0
- package/dist/handlers/emu4-clones.d.ts +2 -0
- package/dist/handlers/emu4-clones.js +322 -0
- package/dist/handlers/emu4-clones.js.map +1 -0
- package/dist/handlers/emu4-compliance.d.ts +8 -0
- package/dist/handlers/emu4-compliance.js +39 -0
- package/dist/handlers/emu4-compliance.js.map +1 -0
- package/dist/handlers/emu4-config.d.ts +14 -0
- package/dist/handlers/emu4-config.js +148 -0
- package/dist/handlers/emu4-config.js.map +1 -0
- package/dist/handlers/emu4-cookies.d.ts +8 -0
- package/dist/handlers/emu4-cookies.js +248 -0
- package/dist/handlers/emu4-cookies.js.map +1 -0
- package/dist/handlers/emu4-erasure.d.ts +2 -0
- package/dist/handlers/emu4-erasure.js +587 -0
- package/dist/handlers/emu4-erasure.js.map +1 -0
- package/dist/handlers/emu4-federation.d.ts +8 -0
- package/dist/handlers/emu4-federation.js +18 -0
- package/dist/handlers/emu4-federation.js.map +1 -0
- package/dist/handlers/emu4-journey-math.d.ts +42 -0
- package/dist/handlers/emu4-journey-math.js +99 -0
- package/dist/handlers/emu4-journey-math.js.map +1 -0
- package/dist/handlers/emu4-messaging.d.ts +21 -0
- package/dist/handlers/emu4-messaging.js +595 -0
- package/dist/handlers/emu4-messaging.js.map +1 -0
- package/dist/handlers/emu4-minors.d.ts +7 -0
- package/dist/handlers/emu4-minors.js +408 -0
- package/dist/handlers/emu4-minors.js.map +1 -0
- package/dist/handlers/emu4-oauth-audit.d.ts +5 -0
- package/dist/handlers/emu4-oauth-audit.js +17 -0
- package/dist/handlers/emu4-oauth-audit.js.map +1 -0
- package/dist/handlers/emu4-oauth-auth.d.ts +83 -0
- package/dist/handlers/emu4-oauth-auth.js +363 -0
- package/dist/handlers/emu4-oauth-auth.js.map +1 -0
- package/dist/handlers/emu4-oauth-delivery.d.ts +68 -0
- package/dist/handlers/emu4-oauth-delivery.js +207 -0
- package/dist/handlers/emu4-oauth-delivery.js.map +1 -0
- package/dist/handlers/emu4-oauth-keys.d.ts +11 -0
- package/dist/handlers/emu4-oauth-keys.js +268 -0
- package/dist/handlers/emu4-oauth-keys.js.map +1 -0
- package/dist/handlers/emu4-oauth-pub.d.ts +36 -0
- package/dist/handlers/emu4-oauth-pub.js +420 -0
- package/dist/handlers/emu4-oauth-pub.js.map +1 -0
- package/dist/handlers/emu4-oauth.d.ts +10 -0
- package/dist/handlers/emu4-oauth.js +23 -0
- package/dist/handlers/emu4-oauth.js.map +1 -0
- package/dist/handlers/emu4-policies.d.ts +2 -0
- package/dist/handlers/emu4-policies.js +408 -0
- package/dist/handlers/emu4-policies.js.map +1 -0
- package/dist/handlers/emu4-region.d.ts +2 -0
- package/dist/handlers/emu4-region.js +189 -0
- package/dist/handlers/emu4-region.js.map +1 -0
- package/dist/handlers/emu4-saml.d.ts +12 -0
- package/dist/handlers/emu4-saml.js +532 -0
- package/dist/handlers/emu4-saml.js.map +1 -0
- package/dist/handlers/emu4-shared.d.ts +44 -0
- package/dist/handlers/emu4-shared.js +90 -0
- package/dist/handlers/emu4-shared.js.map +1 -0
- package/dist/handlers/emu4-snapshots.d.ts +38 -0
- package/dist/handlers/emu4-snapshots.js +231 -0
- package/dist/handlers/emu4-snapshots.js.map +1 -0
- package/dist/handlers/emu4-sns.d.ts +9 -0
- package/dist/handlers/emu4-sns.js +21 -0
- package/dist/handlers/emu4-sns.js.map +1 -0
- package/dist/handlers/emu4-trust-binding.d.ts +27 -0
- package/dist/handlers/emu4-trust-binding.js +344 -0
- package/dist/handlers/emu4-trust-binding.js.map +1 -0
- package/dist/handlers/emu4-trust.d.ts +35 -0
- package/dist/handlers/emu4-trust.js +543 -0
- package/dist/handlers/emu4-trust.js.map +1 -0
- package/dist/handlers/federation.d.ts +2 -0
- package/dist/handlers/federation.js +481 -0
- package/dist/handlers/federation.js.map +1 -0
- package/dist/handlers/fga-engine.d.ts +78 -0
- package/dist/handlers/fga-engine.js +663 -0
- package/dist/handlers/fga-engine.js.map +1 -0
- package/dist/handlers/governance.d.ts +2 -0
- package/dist/handlers/governance.js +460 -0
- package/dist/handlers/governance.js.map +1 -0
- package/dist/handlers/hooks.js +17 -1
- package/dist/handlers/hooks.js.map +1 -1
- package/dist/handlers/index.d.ts +5 -1
- package/dist/handlers/index.js +45 -3
- package/dist/handlers/index.js.map +1 -1
- package/dist/handlers/keys.js +6 -1
- package/dist/handlers/keys.js.map +1 -1
- package/dist/handlers/machines.js +61 -8
- package/dist/handlers/machines.js.map +1 -1
- package/dist/handlers/messaging.d.ts +6 -0
- package/dist/handlers/messaging.js +893 -0
- package/dist/handlers/messaging.js.map +1 -0
- package/dist/handlers/oauth-adv.d.ts +191 -0
- package/dist/handlers/oauth-adv.js +1305 -0
- package/dist/handlers/oauth-adv.js.map +1 -0
- package/dist/handlers/oauth-flows.d.ts +52 -0
- package/dist/handlers/oauth-flows.js +480 -0
- package/dist/handlers/oauth-flows.js.map +1 -0
- package/dist/handlers/oauth.d.ts +96 -0
- package/dist/handlers/oauth.js +793 -0
- package/dist/handlers/oauth.js.map +1 -0
- package/dist/handlers/orgs.js +6 -0
- package/dist/handlers/orgs.js.map +1 -1
- package/dist/handlers/passkeys.d.ts +3 -0
- package/dist/handlers/passkeys.js +293 -0
- package/dist/handlers/passkeys.js.map +1 -0
- package/dist/handlers/platform.d.ts +20 -0
- package/dist/handlers/platform.js +472 -0
- package/dist/handlers/platform.js.map +1 -0
- package/dist/handlers/round2.d.ts +8 -0
- package/dist/handlers/round2.js +372 -0
- package/dist/handlers/round2.js.map +1 -0
- package/dist/handlers/scim.d.ts +4 -0
- package/dist/handlers/scim.js +595 -0
- package/dist/handlers/scim.js.map +1 -0
- package/dist/handlers/sessions.d.ts +9 -3
- package/dist/handlers/sessions.js +76 -38
- package/dist/handlers/sessions.js.map +1 -1
- package/dist/handlers/support.d.ts +20 -0
- package/dist/handlers/support.js +62 -0
- package/dist/handlers/support.js.map +1 -0
- package/dist/handlers/users.js +7 -0
- package/dist/handlers/users.js.map +1 -1
- package/dist/handlers/workflows.d.ts +2 -0
- package/dist/handlers/workflows.js +428 -0
- package/dist/handlers/workflows.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +23 -1
- package/dist/index.js.map +1 -1
- package/dist/jwe.d.ts +18 -0
- package/dist/jwe.js +101 -0
- package/dist/jwe.js.map +1 -0
- package/dist/jws.d.ts +41 -0
- package/dist/jws.js +135 -0
- package/dist/jws.js.map +1 -0
- package/dist/messages.d.ts +7 -0
- package/dist/messages.js +8 -4
- package/dist/messages.js.map +1 -1
- package/dist/messaging-callbacks.d.ts +100 -0
- package/dist/messaging-callbacks.js +767 -0
- package/dist/messaging-callbacks.js.map +1 -0
- package/dist/openapi.js +41 -1
- package/dist/openapi.js.map +1 -1
- package/dist/parity-bin.js +3 -1
- package/dist/parity-bin.js.map +1 -1
- package/dist/parity-emu3-authz.d.ts +3 -0
- package/dist/parity-emu3-authz.js +61 -0
- package/dist/parity-emu3-authz.js.map +1 -0
- package/dist/parity-emu3-billing.d.ts +3 -0
- package/dist/parity-emu3-billing.js +25 -0
- package/dist/parity-emu3-billing.js.map +1 -0
- package/dist/parity-emu3-messaging.d.ts +3 -0
- package/dist/parity-emu3-messaging.js +27 -0
- package/dist/parity-emu3-messaging.js.map +1 -0
- package/dist/parity-emu3-misc.d.ts +2 -0
- package/dist/parity-emu3-misc.js +63 -0
- package/dist/parity-emu3-misc.js.map +1 -0
- package/dist/parity-emu3-oauth.d.ts +2 -0
- package/dist/parity-emu3-oauth.js +39 -0
- package/dist/parity-emu3-oauth.js.map +1 -0
- package/dist/parity-emu4-actions.d.ts +2 -0
- package/dist/parity-emu4-actions.js +38 -0
- package/dist/parity-emu4-actions.js.map +1 -0
- package/dist/parity-emu4-authz.d.ts +3 -0
- package/dist/parity-emu4-authz.js +68 -0
- package/dist/parity-emu4-authz.js.map +1 -0
- package/dist/parity-emu4-billing.d.ts +6 -0
- package/dist/parity-emu4-billing.js +44 -0
- package/dist/parity-emu4-billing.js.map +1 -0
- package/dist/parity-emu4-compliance.d.ts +2 -0
- package/dist/parity-emu4-compliance.js +92 -0
- package/dist/parity-emu4-compliance.js.map +1 -0
- package/dist/parity-emu4-federation.d.ts +2 -0
- package/dist/parity-emu4-federation.js +33 -0
- package/dist/parity-emu4-federation.js.map +1 -0
- package/dist/parity-emu4-messaging.d.ts +3 -0
- package/dist/parity-emu4-messaging.js +42 -0
- package/dist/parity-emu4-messaging.js.map +1 -0
- package/dist/parity-emu4-oauth.d.ts +2 -0
- package/dist/parity-emu4-oauth.js +55 -0
- package/dist/parity-emu4-oauth.js.map +1 -0
- package/dist/parity-emu4-trust.d.ts +2 -0
- package/dist/parity-emu4-trust.js +41 -0
- package/dist/parity-emu4-trust.js.map +1 -0
- package/dist/parity-routes.d.ts +14 -0
- package/dist/parity-routes.js +439 -0
- package/dist/parity-routes.js.map +1 -0
- package/dist/parity.d.ts +10 -3
- package/dist/parity.js +14 -5
- package/dist/parity.js.map +1 -1
- package/dist/sns-cert.d.ts +9 -0
- package/dist/sns-cert.js +101 -0
- package/dist/sns-cert.js.map +1 -0
- package/dist/tokens.d.ts +85 -3
- package/dist/tokens.js +196 -24
- package/dist/tokens.js.map +1 -1
- package/dist/webauthn-virtual.d.ts +70 -0
- package/dist/webauthn-virtual.js +186 -0
- package/dist/webauthn-virtual.js.map +1 -0
- package/dist/webhook-ext.d.ts +16 -0
- package/dist/webhook-ext.js +218 -0
- package/dist/webhook-ext.js.map +1 -0
- package/dist/webhooks.d.ts +3 -0
- package/dist/webhooks.js +37 -3
- package/dist/webhooks.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.keyStore = void 0;
|
|
4
|
+
exports.installMessaging = installMessaging;
|
|
5
|
+
exports.messagingHandlers = messagingHandlers;
|
|
6
|
+
const crypto_util_1 = require("../crypto-util");
|
|
7
|
+
const errors_1 = require("../errors");
|
|
8
|
+
const messaging_callbacks_1 = require("../messaging-callbacks");
|
|
9
|
+
const store_1 = require("../store");
|
|
10
|
+
const support_1 = require("./support");
|
|
11
|
+
const util_1 = require("./util");
|
|
12
|
+
const emu4_sns_1 = require("./emu4-sns");
|
|
13
|
+
/**
|
|
14
|
+
* The messaging layer behind every email, SMS, WhatsApp, voice and push message the engine sends: the message ledger
|
|
15
|
+
* (`GET /v1/messages`, one row per message with attempts and a delivery timeline), the suppression list (a suppressed
|
|
16
|
+
* recipient is never sent to, and bounces and complaints add entries automatically), provider failover (an ordered list of
|
|
17
|
+
* fallback providers tried when the primary fails) and per-country SMS routes. Nothing leaves the process: delivery
|
|
18
|
+
* means the message reached `engine.messages`. To exercise failover, make a provider fail with
|
|
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.
|
|
28
|
+
*/
|
|
29
|
+
const CHANNELS = ['email', 'sms', 'whatsapp', 'voice', 'push'];
|
|
30
|
+
const STATUSES = ['queued', 'sent', 'delivered', 'failed', 'bounced', 'complained', 'suppressed'];
|
|
31
|
+
const REASONS = ['hard_bounce', 'complaint', 'unsubscribe', 'invalid', 'manual'];
|
|
32
|
+
const FALLBACK_CHANNELS = ['email', 'sms', 'push'];
|
|
33
|
+
const TRANSPORTS = {
|
|
34
|
+
email: ['smtp', 'ses', 'sendgrid', 'brevo', 'mailgun', 'postmark', 'resend', 'smtp2go'],
|
|
35
|
+
sms: ['twilio', 'vonage', 'messagebird', 'sinch', 'plivo'],
|
|
36
|
+
push: ['fcm', 'apns', 'onesignal'],
|
|
37
|
+
};
|
|
38
|
+
const RECEIVERS = [
|
|
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.' },
|
|
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();
|
|
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' };
|
|
72
|
+
const E164 = /^\+[1-9]\d{6,14}$/;
|
|
73
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
74
|
+
const MAX_FALLBACKS = 5;
|
|
75
|
+
const MAX_BATCH = 1000;
|
|
76
|
+
const st = (0, support_1.engineState)((e) => ({
|
|
77
|
+
ledger: new store_1.Table('message_ledger', e.clock.now, 'msg'),
|
|
78
|
+
suppressions: new store_1.Table('message_suppressions', e.clock.now, 'msup'),
|
|
79
|
+
fallbacks: new store_1.Table('messaging_fallbacks', e.clock.now, 'mfb'),
|
|
80
|
+
routes: 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,
|
|
86
|
+
settings: { auto_suppress: true, failover_enabled: true, ledger_retention_days: 90, updated_at: null },
|
|
87
|
+
faults: [],
|
|
88
|
+
}));
|
|
89
|
+
exports.keyStore = (0, support_1.engineState)(() => (0, messaging_callbacks_1.testKeys)());
|
|
90
|
+
const mask = (channel, to) => {
|
|
91
|
+
if (channel === 'email' || to.includes('@')) {
|
|
92
|
+
const [l = '', d = ''] = to.split('@');
|
|
93
|
+
return `${l.slice(0, 1)}${'*'.repeat(Math.max(l.length - 1, 1))}@${d}`;
|
|
94
|
+
}
|
|
95
|
+
return `${to.slice(0, 3)}${'*'.repeat(Math.max(to.length - 5, 1))}${to.slice(-2)}`;
|
|
96
|
+
};
|
|
97
|
+
const normalAddress = (channel, a) => (channel === 'email' ? a.trim().toLowerCase() : a.trim());
|
|
98
|
+
const countryOf = (to) => {
|
|
99
|
+
if (!to.startsWith('+'))
|
|
100
|
+
return null;
|
|
101
|
+
for (const len of [3, 2, 1]) {
|
|
102
|
+
const c = CALLING_CODES[to.slice(1, 1 + len)];
|
|
103
|
+
if (c)
|
|
104
|
+
return c;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
};
|
|
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);
|
|
110
|
+
if (!f)
|
|
111
|
+
return false;
|
|
112
|
+
f.times--;
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
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. */
|
|
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
|
+
};
|
|
142
|
+
e.messages.route = (m) => {
|
|
143
|
+
const s = st(e);
|
|
144
|
+
const now = e.clock.now();
|
|
145
|
+
const channel = CHANNELS.includes(m.channel) ? m.channel : 'email';
|
|
146
|
+
const to = normalAddress(channel, m.to);
|
|
147
|
+
let order = slotsFor(s, channel);
|
|
148
|
+
const row = s.ledger.insert({
|
|
149
|
+
channel,
|
|
150
|
+
template: typeof m.meta?.template === 'string' ? m.meta.template : 'custom',
|
|
151
|
+
recipient: to,
|
|
152
|
+
country: channel === 'sms' ? countryOf(to) : null,
|
|
153
|
+
status: 'queued',
|
|
154
|
+
status_detail: null,
|
|
155
|
+
provider: order[0]?.transport ?? null,
|
|
156
|
+
provider_message_id: null,
|
|
157
|
+
attempts: [],
|
|
158
|
+
timeline: [{ status: 'queued', at: now }],
|
|
159
|
+
created_at: now,
|
|
160
|
+
updated_at: now,
|
|
161
|
+
});
|
|
162
|
+
const hit = s.suppressions.find((x) => x.channel === channel && x.address === to);
|
|
163
|
+
if (hit) {
|
|
164
|
+
Object.assign(row, { status: 'suppressed', status_detail: `suppressed: ${hit.reason}`, updated_at: now });
|
|
165
|
+
row.timeline.push({ status: 'suppressed', at: now, detail: hit.reason });
|
|
166
|
+
return { deliver: false };
|
|
167
|
+
}
|
|
168
|
+
// The country's route for SMS goes first, then the rest in order; with failover off only the first is tried.
|
|
169
|
+
const routed = row.country ? s.routes.get(row.country)?.provider_id : undefined;
|
|
170
|
+
if (routed)
|
|
171
|
+
order = [...order.filter((p) => p.id === routed), ...order.filter((p) => p.id !== routed)];
|
|
172
|
+
if (!s.settings.failover_enabled)
|
|
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
|
+
}
|
|
180
|
+
for (const p of order) {
|
|
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() });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
row.attempts.push({ provider: p.transport, provider_id: p.id, channel, status: 'sent', at: e.clock.now() });
|
|
186
|
+
return {
|
|
187
|
+
deliver: true,
|
|
188
|
+
done: () => {
|
|
189
|
+
Object.assign(row, { status: 'delivered', provider: p.transport, provider_message_id: `pm_${row.id.slice(-10)}`, updated_at: e.clock.now() });
|
|
190
|
+
row.timeline.push({ status: 'sent', at: e.clock.now() }, { status: 'delivered', at: e.clock.now() });
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
Object.assign(row, { status: 'failed', status_detail: 'every provider failed', updated_at: e.clock.now() });
|
|
195
|
+
row.timeline.push({ status: 'failed', at: e.clock.now(), detail: 'every provider failed' });
|
|
196
|
+
return { deliver: false };
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const view = async (r) => ({
|
|
200
|
+
object: 'message',
|
|
201
|
+
id: r.id,
|
|
202
|
+
channel: r.channel,
|
|
203
|
+
template: r.template,
|
|
204
|
+
recipient_masked: mask(r.channel, r.recipient),
|
|
205
|
+
recipient_hash: (0, crypto_util_1.bytesToB64url)(await (0, crypto_util_1.sha256)(`${r.channel}:${r.recipient}`)),
|
|
206
|
+
country: r.country,
|
|
207
|
+
status: r.status,
|
|
208
|
+
status_detail: r.status_detail,
|
|
209
|
+
provider: r.provider,
|
|
210
|
+
provider_message_id: r.provider_message_id,
|
|
211
|
+
attempts: r.attempts,
|
|
212
|
+
timeline: r.timeline,
|
|
213
|
+
created_at: r.created_at,
|
|
214
|
+
updated_at: r.updated_at,
|
|
215
|
+
});
|
|
216
|
+
const suppressionView = (r) => ({ object: 'message_suppression', id: r.id, channel: r.channel, address: r.address, address_masked: mask(r.channel, r.address), reason: r.reason, source: r.source, detail: r.detail, message_id: r.message_id, created_at: r.created_at });
|
|
217
|
+
function addSuppression(e, channel, address, reason, source, detail, messageId) {
|
|
218
|
+
const s = st(e);
|
|
219
|
+
const existing = s.suppressions.find((x) => x.channel === channel && x.address === address);
|
|
220
|
+
if (existing)
|
|
221
|
+
return { row: Object.assign(existing, { reason, detail }), created: false };
|
|
222
|
+
const row = s.suppressions.insert({ channel, address, reason, source, detail, message_id: messageId, created_at: e.clock.now() });
|
|
223
|
+
e.webhooks.emit('message_suppression.created', { object: 'message_suppression', id: row.id, channel, recipient: address, reason, source, message_id: messageId, at: e.clock.now() });
|
|
224
|
+
return { row, created: true };
|
|
225
|
+
}
|
|
226
|
+
function checkAddress(channel, raw, param) {
|
|
227
|
+
if (typeof raw !== 'string')
|
|
228
|
+
throw new errors_1.EmulatorError('VALIDATION_FAILED', `${param} must be a string.`, param);
|
|
229
|
+
const a = normalAddress(channel, raw);
|
|
230
|
+
if (channel === 'email' ? !EMAIL.test(a) : channel === 'push' ? !a : !E164.test(a))
|
|
231
|
+
throw new errors_1.EmulatorError('VALIDATION_FAILED', `${param} is not a valid ${channel === 'email' ? 'email address' : channel === 'push' ? 'device id' : 'E.164 number'}.`, param);
|
|
232
|
+
return a;
|
|
233
|
+
}
|
|
234
|
+
const fallbackView = (f) => ({ object: 'messaging_fallback_provider', id: f.id, channel: f.channel, position: f.position, transport: f.transport, from_address: f.from_address, status: f.status, config: f.config, secrets_set: false, updated_at: f.updated_at });
|
|
235
|
+
const channelParam = (v) => {
|
|
236
|
+
if (!FALLBACK_CHANNELS.includes(v ?? ''))
|
|
237
|
+
throw (0, errors_1.notFound)('Messaging channel (use email, sms or push)');
|
|
238
|
+
return v;
|
|
239
|
+
};
|
|
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
|
+
};
|
|
390
|
+
function messagingHandlers() {
|
|
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) });
|
|
392
|
+
const saveFallback = (e, channel, b, existing) => {
|
|
393
|
+
const transport = b.transport ?? existing?.transport;
|
|
394
|
+
if (typeof transport !== 'string' || !TRANSPORTS[channel].includes(transport))
|
|
395
|
+
throw new errors_1.EmulatorError('VALIDATION_FAILED', `transport must be one of: ${TRANSPORTS[channel].join(', ')}.`, 'transport');
|
|
396
|
+
if (b.config !== undefined && (!b.config || typeof b.config !== 'object' || Array.isArray(b.config)))
|
|
397
|
+
throw new errors_1.EmulatorError('VALIDATION_FAILED', 'config must be an object.', 'config');
|
|
398
|
+
const status = (0, support_1.oneOf)(b, 'status', ['active', 'disabled'], existing?.status ?? 'active');
|
|
399
|
+
const mine = st(e).fallbacks.all().filter((f) => f.channel === channel && f.id !== existing?.id);
|
|
400
|
+
const position = b.position === undefined ? (existing?.position ?? mine.length) : (0, support_1.intIn)(b.position, 'position', 0, MAX_FALLBACKS - 1);
|
|
401
|
+
return { channel, transport, status, position, from_address: typeof b.from_address === 'string' && b.from_address.trim() ? b.from_address.trim() : (existing?.from_address ?? null), config: b.config ?? existing?.config ?? {}, updated_at: e.clock.now() };
|
|
402
|
+
};
|
|
403
|
+
return [
|
|
404
|
+
// ── ledger ──
|
|
405
|
+
{
|
|
406
|
+
method: 'GET',
|
|
407
|
+
pattern: '/v1/messages',
|
|
408
|
+
scope: 'messaging:read',
|
|
409
|
+
handler: async (c) => {
|
|
410
|
+
const e = c.engine;
|
|
411
|
+
const q = (k) => c.query.get(k) ?? undefined;
|
|
412
|
+
if (q('channel') && !CHANNELS.includes(q('channel')))
|
|
413
|
+
throw new errors_1.EmulatorError('VALIDATION_FAILED', `channel must be one of: ${CHANNELS.join(', ')}.`, 'channel');
|
|
414
|
+
if (q('status') && !STATUSES.includes(q('status')))
|
|
415
|
+
throw new errors_1.EmulatorError('VALIDATION_FAILED', `status must be one of: ${STATUSES.join(', ')}.`, 'status');
|
|
416
|
+
const time = (k) => {
|
|
417
|
+
const v = q(k);
|
|
418
|
+
if (!v)
|
|
419
|
+
return undefined;
|
|
420
|
+
const n = /^\d+$/.test(v) ? Number(v) : Date.parse(v);
|
|
421
|
+
if (Number.isNaN(n))
|
|
422
|
+
throw new errors_1.EmulatorError('VALIDATION_FAILED', `${k} must be epoch milliseconds or an ISO 8601 time.`, k);
|
|
423
|
+
return n;
|
|
424
|
+
};
|
|
425
|
+
const [since, until] = [time('since'), time('until')];
|
|
426
|
+
const rec = q('recipient')?.trim().toLowerCase();
|
|
427
|
+
const rows = st(e).ledger.all().filter((r) => (!q('channel') || r.channel === q('channel')) && (!q('status') || r.status === q('status')) && (!q('template') || r.template === q('template')) && (!q('provider') || r.provider === q('provider')) && (!rec || r.recipient.toLowerCase() === rec) && (since === undefined || r.created_at >= since) && (until === undefined || r.created_at <= until));
|
|
428
|
+
const limit = (0, support_1.intIn)(q('limit'), 'limit', 1, 100, 25);
|
|
429
|
+
const page = (0, store_1.paginate)(rows, { limit, startingAfter: q('starting_after') });
|
|
430
|
+
return { body: { object: 'list', data: await Promise.all(page.data.map(view)), has_more: page.has_more, next_cursor: page.next_cursor } };
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
method: 'GET',
|
|
435
|
+
pattern: '/v1/messages/summary',
|
|
436
|
+
scope: 'messaging:read',
|
|
437
|
+
handler: (c) => {
|
|
438
|
+
const hours = (0, support_1.intIn)(c.query.get('hours') ?? undefined, 'hours', 1, 720, 24);
|
|
439
|
+
const since = c.engine.clock.now() - hours * 3_600_000;
|
|
440
|
+
const rows = st(c.engine).ledger.all().filter((r) => r.created_at >= since);
|
|
441
|
+
return { body: { object: 'message_summary', since_hours: hours, total: rows.length, by_status: Object.fromEntries(STATUSES.map((x) => [x, rows.filter((r) => r.status === x).length])) } };
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
method: 'GET',
|
|
446
|
+
pattern: '/v1/messages/:id',
|
|
447
|
+
scope: 'messaging:read',
|
|
448
|
+
handler: async (c) => {
|
|
449
|
+
const r = st(c.engine).ledger.get(c.params.id);
|
|
450
|
+
if (!r)
|
|
451
|
+
throw (0, errors_1.notFound)('Message');
|
|
452
|
+
return { body: await view(r) };
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
// ── suppressions ──
|
|
456
|
+
{
|
|
457
|
+
method: 'GET',
|
|
458
|
+
pattern: '/v1/suppressions',
|
|
459
|
+
scope: 'messaging:read',
|
|
460
|
+
handler: (c) => {
|
|
461
|
+
const q = (k) => c.query.get(k) ?? undefined;
|
|
462
|
+
const rows = st(c.engine).suppressions.all().filter((r) => (!q('channel') || r.channel === q('channel')) && (!q('reason') || r.reason === q('reason')) && (!q('address') || r.address === normalAddress(r.channel, q('address'))));
|
|
463
|
+
const page = (0, store_1.paginate)(rows, (0, util_1.pageOf)(c));
|
|
464
|
+
return { body: { object: 'list', data: page.data.map(suppressionView), has_more: page.has_more, next_cursor: page.next_cursor } };
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
method: 'POST',
|
|
469
|
+
pattern: '/v1/suppressions',
|
|
470
|
+
scope: 'messaging:write',
|
|
471
|
+
handler: (c) => {
|
|
472
|
+
const e = c.engine;
|
|
473
|
+
const channel = (0, support_1.oneOf)(c.body, 'channel', CHANNELS, 'email');
|
|
474
|
+
const reason = (0, support_1.oneOf)(c.body, 'reason', REASONS, 'manual');
|
|
475
|
+
const detail = c.body.detail ?? null;
|
|
476
|
+
if (detail !== null && (typeof detail !== 'string' || detail.length > 300))
|
|
477
|
+
throw new errors_1.EmulatorError('VALIDATION_FAILED', 'detail must be text up to 300 characters.', 'detail');
|
|
478
|
+
const raw = c.body.addresses !== undefined ? c.body.addresses : c.body.address !== undefined ? [c.body.address] : undefined;
|
|
479
|
+
if (!Array.isArray(raw) || !raw.length || raw.length > MAX_BATCH)
|
|
480
|
+
throw new errors_1.EmulatorError('VALIDATION_FAILED', `Send address, or addresses (1 to ${MAX_BATCH}).`, 'address');
|
|
481
|
+
const addresses = [...new Set(raw.map((a, i) => checkAddress(channel, a, `addresses[${i}]`)))];
|
|
482
|
+
let created = 0;
|
|
483
|
+
const out = addresses.map((a) => {
|
|
484
|
+
const r = addSuppression(e, channel, a, reason, 'api', detail, null);
|
|
485
|
+
if (r.created)
|
|
486
|
+
created++;
|
|
487
|
+
return r.row;
|
|
488
|
+
});
|
|
489
|
+
return { status: 201, body: { object: 'list', data: out.map(suppressionView), has_more: false, next_cursor: null, created } };
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
method: 'GET',
|
|
494
|
+
pattern: '/v1/suppressions/:id',
|
|
495
|
+
scope: 'messaging:read',
|
|
496
|
+
handler: (c) => {
|
|
497
|
+
const r = st(c.engine).suppressions.get(c.params.id);
|
|
498
|
+
if (!r)
|
|
499
|
+
throw (0, errors_1.notFound)('Suppression');
|
|
500
|
+
return { body: suppressionView(r) };
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
method: 'DELETE',
|
|
505
|
+
pattern: '/v1/suppressions/:id',
|
|
506
|
+
scope: 'messaging:write',
|
|
507
|
+
handler: (c) => {
|
|
508
|
+
if (!st(c.engine).suppressions.delete(c.params.id ?? ''))
|
|
509
|
+
throw (0, errors_1.notFound)('Suppression');
|
|
510
|
+
return { body: { object: 'message_suppression', id: c.params.id, deleted: true } };
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
// ── failover providers ──
|
|
514
|
+
{ method: 'GET', pattern: '/v1/messaging_providers/:channel/fallbacks', scope: 'messaging:read', handler: (c) => ({ body: list(c.engine, channelParam(c.params.channel)) }) },
|
|
515
|
+
{
|
|
516
|
+
method: 'POST',
|
|
517
|
+
pattern: '/v1/messaging_providers/:channel/fallbacks',
|
|
518
|
+
scope: 'messaging:write',
|
|
519
|
+
handler: (c) => {
|
|
520
|
+
const e = c.engine;
|
|
521
|
+
const channel = channelParam(c.params.channel);
|
|
522
|
+
if (st(e).fallbacks.filter((f) => f.channel === channel).length >= MAX_FALLBACKS)
|
|
523
|
+
throw new errors_1.EmulatorError('CONFLICT', `At most ${MAX_FALLBACKS} fallback providers per channel.`);
|
|
524
|
+
return { status: 201, body: fallbackView(st(e).fallbacks.insert(saveFallback(e, channel, c.body))) };
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
method: 'PUT',
|
|
529
|
+
pattern: '/v1/messaging_providers/:channel/fallbacks/order',
|
|
530
|
+
scope: 'messaging:write',
|
|
531
|
+
handler: (c) => {
|
|
532
|
+
const e = c.engine;
|
|
533
|
+
const channel = channelParam(c.params.channel);
|
|
534
|
+
const mine = st(e).fallbacks.filter((f) => f.channel === channel);
|
|
535
|
+
const ids = c.body.ids;
|
|
536
|
+
if (!Array.isArray(ids) || ids.length !== mine.length || new Set(ids).size !== ids.length || ids.some((i) => !mine.find((f) => f.id === i)))
|
|
537
|
+
throw new errors_1.EmulatorError('VALIDATION_FAILED', 'ids must list every fallback provider of the channel exactly once.', 'ids');
|
|
538
|
+
ids.forEach((id, i) => Object.assign(st(e).fallbacks.get(id), { position: i }));
|
|
539
|
+
return { body: list(e, channel) };
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
method: 'PUT',
|
|
544
|
+
pattern: '/v1/messaging_providers/:channel/fallbacks/:id',
|
|
545
|
+
scope: 'messaging:write',
|
|
546
|
+
handler: (c) => {
|
|
547
|
+
const e = c.engine;
|
|
548
|
+
const channel = channelParam(c.params.channel);
|
|
549
|
+
const f = st(e).fallbacks.get(c.params.id);
|
|
550
|
+
if (!f || f.channel !== channel)
|
|
551
|
+
throw (0, errors_1.notFound)('Fallback provider');
|
|
552
|
+
return { body: fallbackView(Object.assign(f, saveFallback(e, channel, c.body, f))) };
|
|
553
|
+
},
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
method: 'DELETE',
|
|
557
|
+
pattern: '/v1/messaging_providers/:channel/fallbacks/:id',
|
|
558
|
+
scope: 'messaging:write',
|
|
559
|
+
handler: (c) => {
|
|
560
|
+
const channel = channelParam(c.params.channel);
|
|
561
|
+
const f = st(c.engine).fallbacks.get(c.params.id);
|
|
562
|
+
if (!f || f.channel !== channel)
|
|
563
|
+
throw (0, errors_1.notFound)('Fallback provider');
|
|
564
|
+
st(c.engine).fallbacks.delete(f.id);
|
|
565
|
+
for (const [country, r] of st(c.engine).routes)
|
|
566
|
+
if (r.provider_id === f.id)
|
|
567
|
+
st(c.engine).routes.delete(country);
|
|
568
|
+
return { body: { object: 'messaging_fallback_provider', id: f.id, deleted: true } };
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
// ── per-country SMS routes ──
|
|
572
|
+
{
|
|
573
|
+
method: 'GET',
|
|
574
|
+
pattern: '/v1/messaging/country_routes',
|
|
575
|
+
scope: 'messaging:read',
|
|
576
|
+
handler: (c) => ({ body: { object: 'list', data: [...st(c.engine).routes.values()], has_more: false } }),
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
method: 'PUT',
|
|
580
|
+
pattern: '/v1/messaging/country_routes',
|
|
581
|
+
scope: 'messaging:write',
|
|
582
|
+
handler: (c) => {
|
|
583
|
+
const e = c.engine;
|
|
584
|
+
const country = typeof c.body.country === 'string' ? c.body.country.toUpperCase() : '';
|
|
585
|
+
if (!/^[A-Z]{2}$/.test(country))
|
|
586
|
+
throw new errors_1.EmulatorError('VALIDATION_FAILED', 'country must be a two-letter ISO 3166 country code, e.g. DE.', 'country');
|
|
587
|
+
const pid = c.body.provider_id;
|
|
588
|
+
if (typeof pid !== 'string' || !failoverOrder(e, 'sms').includes(pid))
|
|
589
|
+
throw new errors_1.EmulatorError('VALIDATION_FAILED', "provider_id is not one of this instance's active SMS providers (primary or an active fallback).", 'provider_id');
|
|
590
|
+
const row = { object: 'sms_country_route', id: `scr_${country}`, channel: 'sms', country, provider_id: pid, updated_at: e.clock.now() };
|
|
591
|
+
st(e).routes.set(country, row);
|
|
592
|
+
return { body: row };
|
|
593
|
+
},
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
method: 'DELETE',
|
|
597
|
+
pattern: '/v1/messaging/country_routes/:country',
|
|
598
|
+
scope: 'messaging:write',
|
|
599
|
+
handler: (c) => {
|
|
600
|
+
const country = (c.params.country ?? '').toUpperCase();
|
|
601
|
+
if (!st(c.engine).routes.delete(country))
|
|
602
|
+
throw (0, errors_1.notFound)('Route for that country');
|
|
603
|
+
return { body: { object: 'sms_country_route', country, deleted: true } };
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
// ── settings ──
|
|
607
|
+
{ method: 'GET', pattern: '/v1/messaging/settings', scope: 'messaging:read', handler: (c) => ({ body: { object: 'messaging_settings', ...st(c.engine).settings } }) },
|
|
608
|
+
{
|
|
609
|
+
method: 'PUT',
|
|
610
|
+
pattern: '/v1/messaging/settings',
|
|
611
|
+
scope: 'messaging:write',
|
|
612
|
+
handler: (c) => {
|
|
613
|
+
const s = st(c.engine).settings;
|
|
614
|
+
for (const k of ['auto_suppress', 'failover_enabled']) {
|
|
615
|
+
if (c.body[k] === undefined)
|
|
616
|
+
continue;
|
|
617
|
+
if (typeof c.body[k] !== 'boolean')
|
|
618
|
+
throw new errors_1.EmulatorError('VALIDATION_FAILED', `${k} must be true or false.`, k);
|
|
619
|
+
s[k] = c.body[k];
|
|
620
|
+
}
|
|
621
|
+
if (c.body.ledger_retention_days !== undefined)
|
|
622
|
+
s.ledger_retention_days = (0, support_1.intIn)(c.body.ledger_retention_days, 'ledger_retention_days', 7, 730);
|
|
623
|
+
s.updated_at = c.engine.clock.now();
|
|
624
|
+
return { body: { object: 'messaging_settings', ...s } };
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
// ── per-channel providers ──
|
|
628
|
+
{
|
|
629
|
+
method: 'GET',
|
|
630
|
+
pattern: '/v1/messaging_providers',
|
|
631
|
+
scope: 'messaging:read',
|
|
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
|
+
},
|
|
636
|
+
},
|
|
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) => ({
|
|
650
|
+
method: 'PUT',
|
|
651
|
+
pattern: `/v1/messaging_providers/${channel}`,
|
|
652
|
+
scope: 'messaging:write',
|
|
653
|
+
handler: (c) => {
|
|
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)) };
|
|
681
|
+
},
|
|
682
|
+
})),
|
|
683
|
+
{
|
|
684
|
+
method: 'DELETE',
|
|
685
|
+
pattern: '/v1/messaging_providers/:channel',
|
|
686
|
+
scope: 'messaging:write',
|
|
687
|
+
handler: (c) => {
|
|
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 } };
|
|
696
|
+
},
|
|
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
|
+
}))),
|
|
787
|
+
// ── emulator controls: make a provider fail, play a delivery callback ──
|
|
788
|
+
{
|
|
789
|
+
method: 'POST',
|
|
790
|
+
pattern: '/__emulator/messaging/faults',
|
|
791
|
+
public: true,
|
|
792
|
+
handler: (c) => {
|
|
793
|
+
const times = (0, support_1.intIn)(c.body.times, 'times', 1, 1000, 1);
|
|
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 });
|
|
801
|
+
return { status: 201, body: { ok: true } };
|
|
802
|
+
},
|
|
803
|
+
},
|
|
804
|
+
{
|
|
805
|
+
method: 'POST',
|
|
806
|
+
pattern: '/__emulator/messaging/feedback',
|
|
807
|
+
public: true,
|
|
808
|
+
handler: (c) => {
|
|
809
|
+
const e = c.engine;
|
|
810
|
+
const s = st(e);
|
|
811
|
+
const row = s.ledger.get(String(c.body.message_id ?? ''));
|
|
812
|
+
if (!row)
|
|
813
|
+
throw (0, errors_1.notFound)('Message');
|
|
814
|
+
const event = (0, support_1.oneOf)(c.body, 'event', ['delivered', 'bounced', 'complained']);
|
|
815
|
+
const now = e.clock.now();
|
|
816
|
+
const status = event;
|
|
817
|
+
Object.assign(row, { status, updated_at: now, status_detail: c.body.detail ?? null });
|
|
818
|
+
row.timeline.push({ status, at: now, ...(c.body.detail ? { detail: c.body.detail } : {}) });
|
|
819
|
+
// A hard bounce or a complaint suppresses the address, unless auto-suppression is off.
|
|
820
|
+
if (event !== 'delivered' && s.settings.auto_suppress)
|
|
821
|
+
addSuppression(e, row.channel, row.recipient, event === 'bounced' ? 'hard_bounce' : 'complaint', 'provider', c.body.detail ?? null, row.id);
|
|
822
|
+
return { body: { ok: true, status } };
|
|
823
|
+
},
|
|
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
|
+
},
|
|
891
|
+
];
|
|
892
|
+
}
|
|
893
|
+
//# sourceMappingURL=messaging.js.map
|