@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.
- package/LICENSE +21 -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 +1 -1
- package/dist/engine.js +5 -1
- package/dist/engine.js.map +1 -1
- package/dist/error-catalog.js +8 -0
- package/dist/error-catalog.js.map +1 -1
- package/dist/event-gate.js +60 -2
- package/dist/event-gate.js.map +1 -1
- package/dist/generic.js +5 -1
- 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.js +54 -1
- package/dist/handlers/approvals.js.map +1 -1
- 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.js +240 -177
- package/dist/handlers/authz.js.map +1 -1
- package/dist/handlers/billing.d.ts +99 -1
- package/dist/handlers/billing.js +556 -77
- package/dist/handlers/billing.js.map +1 -1
- 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 +4 -1
- package/dist/handlers/connections.js +267 -38
- package/dist/handlers/connections.js.map +1 -1
- package/dist/handlers/control.js +6 -1
- package/dist/handlers/control.js.map +1 -1
- package/dist/handlers/device.js +18 -1
- package/dist/handlers/device.js.map +1 -1
- 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/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.js +3 -2
- package/dist/handlers/governance.js.map +1 -1
- package/dist/handlers/hooks.js +13 -0
- package/dist/handlers/hooks.js.map +1 -1
- package/dist/handlers/index.js +14 -1
- package/dist/handlers/index.js.map +1 -1
- package/dist/handlers/machines.js +6 -0
- package/dist/handlers/machines.js.map +1 -1
- package/dist/handlers/messaging.d.ts +3 -1
- package/dist/handlers/messaging.js +455 -48
- package/dist/handlers/messaging.js.map +1 -1
- 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 +25 -1
- package/dist/handlers/oauth-flows.js +111 -50
- package/dist/handlers/oauth-flows.js.map +1 -1
- package/dist/handlers/oauth.d.ts +24 -3
- package/dist/handlers/oauth.js +245 -60
- package/dist/handlers/oauth.js.map +1 -1
- package/dist/handlers/orgs.js +6 -0
- package/dist/handlers/orgs.js.map +1 -1
- package/dist/handlers/platform.d.ts +5 -0
- package/dist/handlers/platform.js +33 -15
- package/dist/handlers/platform.js.map +1 -1
- package/dist/handlers/scim.d.ts +2 -0
- package/dist/handlers/scim.js +198 -11
- package/dist/handlers/scim.js.map +1 -1
- package/dist/handlers/sessions.d.ts +2 -0
- package/dist/handlers/sessions.js +24 -16
- package/dist/handlers/sessions.js.map +1 -1
- package/dist/handlers/users.js +3 -0
- package/dist/handlers/users.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.js +2 -0
- package/dist/jws.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.d.ts +1 -0
- package/dist/openapi.js +44 -2
- package/dist/openapi.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.js +19 -1
- package/dist/parity-routes.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 +69 -4
- package/dist/tokens.js +164 -38
- package/dist/tokens.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.journeyEntry = journeyEntry;
|
|
4
|
+
exports.scheduleJourneyMessages = scheduleJourneyMessages;
|
|
5
|
+
exports.deliverDueJourneyMessages = deliverDueJourneyMessages;
|
|
6
|
+
exports.emu4MessagingHandlers = emu4MessagingHandlers;
|
|
7
|
+
const errors_1 = require("../errors");
|
|
8
|
+
const generic_1 = require("../generic");
|
|
9
|
+
const store_1 = require("../store");
|
|
10
|
+
const sns_cert_1 = require("../sns-cert");
|
|
11
|
+
const messaging_callbacks_1 = require("../messaging-callbacks");
|
|
12
|
+
const platform_1 = require("./platform");
|
|
13
|
+
const emu4_journey_math_1 = require("./emu4-journey-math");
|
|
14
|
+
const messaging_1 = require("./messaging");
|
|
15
|
+
const emu4_sns_1 = require("./emu4-sns");
|
|
16
|
+
const support_1 = require("./support");
|
|
17
|
+
const util_1 = require("./util");
|
|
18
|
+
/**
|
|
19
|
+
* Round-four messaging: journeys that keep saved versions, A/B experiments over those versions, the messages a journey's
|
|
20
|
+
* `send_message` steps schedule (email and text, delivered through the message sink when the engine clock reaches their
|
|
21
|
+
* time), and the Amazon SNS certificate endpoints. Event names, route shapes and error messages follow the real API.
|
|
22
|
+
*/
|
|
23
|
+
const MAX_EXPERIMENTS_PER_JOURNEY = 50;
|
|
24
|
+
const MAX_DELAY_SECONDS = 30 * 24 * 3600;
|
|
25
|
+
const MAX_ATTEMPTS = 3;
|
|
26
|
+
const MESSAGE_STATUSES = ['pending', 'sending', 'sent', 'failed', 'canceled'];
|
|
27
|
+
const SNS_HOST = /^sns\.[a-z0-9-]+\.amazonaws\.com(\.cn)?$/;
|
|
28
|
+
const st = (0, support_1.engineState)((e) => ({ versions: new Map(), experiments: new store_1.Table('journey_experiments', e.clock.now, 'jexp'), assignments: new Map(), messages: new store_1.Table('journey_messages', e.clock.now, 'jmsg'), audit: [] }));
|
|
29
|
+
const bad = (message, param) => new errors_1.EmulatorError('VALIDATION_FAILED', message, param);
|
|
30
|
+
const conflict = (message) => new errors_1.EmulatorError('CONFLICT', message);
|
|
31
|
+
const flows = (e) => e.generic.get('flows');
|
|
32
|
+
const flowOf = (e, id) => {
|
|
33
|
+
const f = flows(e)?.get(id);
|
|
34
|
+
if (!f)
|
|
35
|
+
throw new errors_1.EmulatorError('NOT_FOUND', 'Unknown journey.');
|
|
36
|
+
return f;
|
|
37
|
+
};
|
|
38
|
+
const strip = ({ _parent, ...row }) => row;
|
|
39
|
+
function audit(e, action, targetType, targetId, metadata = {}) {
|
|
40
|
+
st(e).audit.push({ id: e.db.newId('audit'), action, actor_type: 'api_key', target_type: targetType, target_id: targetId, ip: null, metadata, created_at: e.clock.now() });
|
|
41
|
+
}
|
|
42
|
+
// ── journey versions ─────────────────────────────────────────────────
|
|
43
|
+
/** Saved versions of a journey, oldest first; a journey made before versions were tracked starts at version 1 with its stored definition. */
|
|
44
|
+
function versionsOf(e, flow) {
|
|
45
|
+
const s = st(e).versions;
|
|
46
|
+
let list = s.get(flow.id);
|
|
47
|
+
if (!list) {
|
|
48
|
+
list = [{ version: 1, definition: flow.definition ?? {}, created_at: flow.created_at ?? e.clock.now() }];
|
|
49
|
+
s.set(flow.id, list);
|
|
50
|
+
flows(e).update(flow.id, { latest_version: 1, active_version: flow.active_version === undefined ? 1 : flow.active_version });
|
|
51
|
+
}
|
|
52
|
+
return list;
|
|
53
|
+
}
|
|
54
|
+
const activeDefinition = (e, flow) => (flow.active_version == null ? (flow.definition ?? {}) : (versionsOf(e, flow).find((v) => v.version === flow.active_version)?.definition ?? flow.definition ?? {}));
|
|
55
|
+
const versionView = (v) => ({ object: 'sign_in_journey_version', version: v.version, definition: v.definition, created_by: null, created_at: v.created_at });
|
|
56
|
+
// ── experiments ──────────────────────────────────────────────────────
|
|
57
|
+
const experimentView = (x) => ({ object: 'journey_experiment', id: x.id, journey_id: x.journey_id, name: x.name, hypothesis: x.hypothesis ?? null, status: x.status, variants: x.variants, promoted_variant: x.promoted_variant ?? null, started_at: x.started_at ?? null, stopped_at: x.stopped_at ?? null, created_at: x.created_at, updated_at: x.updated_at });
|
|
58
|
+
const expOf = (e, flowId, id) => {
|
|
59
|
+
const x = st(e).experiments.get(id);
|
|
60
|
+
if (!x || x.journey_id !== flowId)
|
|
61
|
+
throw new errors_1.EmulatorError('NOT_FOUND', 'Unknown experiment.');
|
|
62
|
+
return x;
|
|
63
|
+
};
|
|
64
|
+
const runningOn = (e, flowId) => st(e).experiments.find((x) => x.journey_id === flowId && x.status === 'running');
|
|
65
|
+
function parseExperimentBody(e, flow, body, mode) {
|
|
66
|
+
const KNOWN = ['name', 'hypothesis', 'variants'];
|
|
67
|
+
const unknown = Object.keys(body).find((k) => !KNOWN.includes(k));
|
|
68
|
+
if (unknown)
|
|
69
|
+
throw bad(`Unrecognised field ${unknown}. Expected any of ${KNOWN.join(', ')}.`, unknown);
|
|
70
|
+
const out = {};
|
|
71
|
+
if (body.name !== undefined || mode === 'create') {
|
|
72
|
+
if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 100)
|
|
73
|
+
throw bad('name is required (1-100 characters).', 'name');
|
|
74
|
+
out.name = body.name.trim();
|
|
75
|
+
}
|
|
76
|
+
if (body.hypothesis !== undefined) {
|
|
77
|
+
if (body.hypothesis !== null && (typeof body.hypothesis !== 'string' || body.hypothesis.length > 500))
|
|
78
|
+
throw bad('hypothesis: at most 500 characters.', 'hypothesis');
|
|
79
|
+
out.hypothesis = body.hypothesis;
|
|
80
|
+
}
|
|
81
|
+
if (body.variants !== undefined || mode === 'create') {
|
|
82
|
+
const have = new Set(versionsOf(e, flow).map((v) => v.version));
|
|
83
|
+
const problem = (0, emu4_journey_math_1.validateVariants)(body.variants, (n) => have.has(n));
|
|
84
|
+
if (problem)
|
|
85
|
+
throw bad(problem.message, problem.path);
|
|
86
|
+
out.variants = body.variants;
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* What a user reaching a journey runs: the version their experiment variant names when an experiment is running (the choice is stored at the first
|
|
92
|
+
* visit, so later changes never move anybody), else the active version. An experiment problem never fails the sign-in.
|
|
93
|
+
*/
|
|
94
|
+
async function journeyEntry(e, flow, userId) {
|
|
95
|
+
const plain = () => {
|
|
96
|
+
const def = activeDefinition(e, flow);
|
|
97
|
+
return { steps: (Array.isArray(def.steps) ? def.steps : flow.steps) ?? [], version: flow.active_version ?? null, experiment_id: null, variant: null };
|
|
98
|
+
};
|
|
99
|
+
try {
|
|
100
|
+
const x = runningOn(e, flow.id);
|
|
101
|
+
if (!x)
|
|
102
|
+
return plain();
|
|
103
|
+
const key = `${x.id}|${userId}`;
|
|
104
|
+
let a = st(e).assignments.get(key);
|
|
105
|
+
if (!a) {
|
|
106
|
+
const v = (0, emu4_journey_math_1.variantForBucket)(x.variants, await (0, emu4_journey_math_1.experimentBucket)(x.id, userId));
|
|
107
|
+
st(e).assignments.set(key, (a = { experiment_id: x.id, user_id: userId, variant: v.key, version: v.version, assigned_at: e.clock.now() }));
|
|
108
|
+
}
|
|
109
|
+
const def = versionsOf(e, flow).find((v) => v.version === a.version)?.definition;
|
|
110
|
+
if (!def)
|
|
111
|
+
return plain();
|
|
112
|
+
return { steps: (Array.isArray(def.steps) ? def.steps : []), version: a.version, experiment_id: x.id, variant: a.variant };
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return plain();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function readout(e, x) {
|
|
119
|
+
const runs = (0, platform_1.journeys)(e).runs.filter((r) => r.experiment_id === x.id);
|
|
120
|
+
const counts = x.variants.map((v) => {
|
|
121
|
+
const mine = runs.filter((r) => r.variant === v.key);
|
|
122
|
+
const done = mine.filter((r) => r.status === 'completed');
|
|
123
|
+
return { key: v.key, assigned: [...st(e).assignments.values()].filter((a) => a.experiment_id === x.id && a.variant === v.key).length, started: mine.length, completed: done.length, abandoned: mine.filter((r) => r.status === 'abandoned').length, durationMsTotal: done.reduce((n, r) => n + Math.max((r.completed_at ?? r.updated_at) - r.started_at, 0), 0) };
|
|
124
|
+
});
|
|
125
|
+
const byKey = new Map(x.variants.map((v) => [v.key, v]));
|
|
126
|
+
return {
|
|
127
|
+
object: 'journey_experiment_readout',
|
|
128
|
+
experiment_id: x.id,
|
|
129
|
+
journey_id: x.journey_id,
|
|
130
|
+
status: x.status,
|
|
131
|
+
metric: 'completion',
|
|
132
|
+
control: x.variants[0]?.key ?? null,
|
|
133
|
+
significance_level: 0.05,
|
|
134
|
+
started_at: x.started_at ?? null,
|
|
135
|
+
stopped_at: x.stopped_at ?? null,
|
|
136
|
+
variants: (0, emu4_journey_math_1.experimentReadout)(counts).map((v) => ({ key: v.key, version: byKey.get(v.key)?.version ?? null, weight: byKey.get(v.key)?.weight ?? null, assigned: v.assigned, started: v.started, completed: v.completed, abandoned: v.abandoned, in_progress: v.in_progress, completion_rate: v.completion_rate, abandon_rate: v.abandon_rate, avg_duration_ms: v.avg_duration_ms, completion_interval: v.completion_interval, lift: v.lift, p_value: v.p_value, significant: v.significant })),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
// ── scheduled messages ───────────────────────────────────────────────
|
|
140
|
+
const messageView = (m) => ({ object: 'journey_message', id: m.id, journey_id: m.journey_id, step: m.step, user_id: m.user_id, channel: m.channel, subject: m.subject, status: m.status, send_at: m.send_at, attempts: m.attempts, last_error: m.last_error ?? null, sent_at: m.sent_at ?? null, created_at: m.created_at });
|
|
141
|
+
/** One row per user per step and channel, ever: a second schedule of the same step changes nothing. Returns how many rows were added. */
|
|
142
|
+
function schedule(e, input) {
|
|
143
|
+
const t = st(e).messages;
|
|
144
|
+
const now = e.clock.now();
|
|
145
|
+
let added = 0;
|
|
146
|
+
for (const m of input.messages) {
|
|
147
|
+
const base = { journey_id: input.journey_id, user_id: input.user_id, step: m.step, send_at: now + m.delaySeconds * 1000, status: 'pending', attempts: 0, last_error: null, sent_at: null, created_at: now };
|
|
148
|
+
for (const row of [{ ...base, channel: 'email', subject: m.subject, body: m.body }, ...(m.smsBody ? [{ ...base, channel: 'sms', subject: '', body: m.smsBody }] : [])]) {
|
|
149
|
+
if (t.find((x) => x.journey_id === row.journey_id && x.user_id === row.user_id && x.step === row.step && x.channel === row.channel))
|
|
150
|
+
continue;
|
|
151
|
+
t.insert(row);
|
|
152
|
+
added++;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return added;
|
|
156
|
+
}
|
|
157
|
+
/** A run that finished the whole journey schedules the journey's `send_message` steps, each after the `delay` steps before it (the version the run followed). */
|
|
158
|
+
function scheduleJourneyMessages(e, run) {
|
|
159
|
+
const flow = flows(e)?.get(run.flow_id);
|
|
160
|
+
if (!flow)
|
|
161
|
+
return 0;
|
|
162
|
+
const def = (run.journey_version != null ? versionsOf(e, flow).find((v) => v.version === run.journey_version)?.definition : undefined) ?? activeDefinition(e, flow);
|
|
163
|
+
let delay = 0;
|
|
164
|
+
const messages = [];
|
|
165
|
+
for (const s of (Array.isArray(def.steps) ? def.steps : [])) {
|
|
166
|
+
if (s.type === 'delay')
|
|
167
|
+
delay = Math.min(delay + (Number(s.delay_seconds) || 0), MAX_DELAY_SECONDS);
|
|
168
|
+
else if (s.type === 'send_message')
|
|
169
|
+
messages.push({ step: String(s.id), subject: String(s.subject ?? ''), body: String(s.body ?? ''), delaySeconds: delay, ...(s.sms_body ? { smsBody: String(s.sms_body) } : {}) });
|
|
170
|
+
}
|
|
171
|
+
return schedule(e, { journey_id: flow.id, user_id: run.user_id, messages });
|
|
172
|
+
}
|
|
173
|
+
const fill = (text, vars) => text.replace(/\{\{\s*(first_name|last_name|email|user_id)\s*\}\}/g, (_m, k) => vars[k] ?? '');
|
|
174
|
+
/** Deliver every message whose time has come on the engine clock: to the user's verified primary email or phone, through the message sink (so suppression, providers and the ledger apply). */
|
|
175
|
+
function deliverDueJourneyMessages(e) {
|
|
176
|
+
const out = { sent: 0, retried: 0, failed: 0 };
|
|
177
|
+
const now = e.clock.now();
|
|
178
|
+
const due = st(e).messages.filter((m) => m.status === 'pending' && m.send_at <= now).reverse();
|
|
179
|
+
for (const m of due) {
|
|
180
|
+
const flow = flows(e)?.get(m.journey_id);
|
|
181
|
+
if (!flow) {
|
|
182
|
+
Object.assign(m, { status: 'canceled', last_error: 'The journey was deleted.' });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
m.attempts += 1;
|
|
186
|
+
const user = e.db.users.get(m.user_id);
|
|
187
|
+
const data = { object: 'journey_message', journey_id: m.journey_id, user_id: m.user_id, step: m.step, channel: m.channel };
|
|
188
|
+
let error = null;
|
|
189
|
+
let retry = true;
|
|
190
|
+
let sunk;
|
|
191
|
+
if (!user || user.deleted || user.banned)
|
|
192
|
+
[error, retry] = ['The user was deleted or banned.', false];
|
|
193
|
+
else {
|
|
194
|
+
const sms = m.channel === 'sms';
|
|
195
|
+
const to = sms ? (user.phone?.verified ? user.phone.phone_number : null) : (user.emails.find((x) => x.id === user.primary_email_address_id && x.verified)?.email_address ?? null);
|
|
196
|
+
if (!to)
|
|
197
|
+
[error, retry] = [sms ? 'The user has no verified phone number.' : 'The user has no verified email address.', false];
|
|
198
|
+
else {
|
|
199
|
+
const vars = { email: sms ? '' : to, first_name: user.first_name ?? '', last_name: user.last_name ?? '', user_id: user.id };
|
|
200
|
+
sunk = e.messages.send({ channel: m.channel, to, ...(sms ? {} : { subject: fill(m.subject, vars) }), body: fill(m.body, vars), meta: { template: 'journey_message', journey_id: m.journey_id, step: m.step, message_id: m.id } });
|
|
201
|
+
if (sunk.id === 0)
|
|
202
|
+
error = 'The message was not delivered (suppressed, or the provider failed).';
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (!error) {
|
|
206
|
+
Object.assign(m, { status: 'sent', sent_at: now, last_error: null });
|
|
207
|
+
e.webhooks.emit('journey.message_sent', { ...data, message_id: m.id, at: now });
|
|
208
|
+
out.sent++;
|
|
209
|
+
}
|
|
210
|
+
else if (retry && m.attempts < MAX_ATTEMPTS) {
|
|
211
|
+
Object.assign(m, { last_error: error, send_at: now + m.attempts * 10 * 60_000 });
|
|
212
|
+
out.retried++;
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
Object.assign(m, { status: 'failed', last_error: error });
|
|
216
|
+
e.webhooks.emit('journey.message_failed', { ...data, reason: retry ? 'Delivery failed after retries.' : error, at: now });
|
|
217
|
+
out.failed++;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
// ── routes ───────────────────────────────────────────────────────────
|
|
223
|
+
const pemOf = (b) => (0, sns_cert_1.toPem)('PRIVATE KEY', b);
|
|
224
|
+
function emu4MessagingHandlers() {
|
|
225
|
+
const exp = (c) => {
|
|
226
|
+
const flow = flowOf(c.engine, c.params.id);
|
|
227
|
+
return { flow, x: expOf(c.engine, flow.id, c.params.experimentId) };
|
|
228
|
+
};
|
|
229
|
+
const withDef = (e, flow) => ({ ...strip(flow), definition: activeDefinition(e, flow) });
|
|
230
|
+
return [
|
|
231
|
+
// ── journey versions (what experiments run) ──
|
|
232
|
+
{
|
|
233
|
+
method: 'POST',
|
|
234
|
+
pattern: '/v1/flows',
|
|
235
|
+
scope: 'actions:write',
|
|
236
|
+
handler: (c) => {
|
|
237
|
+
const e = c.engine;
|
|
238
|
+
const r = (0, generic_1.genericHandler)(c);
|
|
239
|
+
const row = flows(e).get(r.body.id);
|
|
240
|
+
const def = c.body.definition ?? row.definition ?? {};
|
|
241
|
+
st(e).versions.set(row.id, [{ version: 1, definition: def, created_at: e.clock.now() }]);
|
|
242
|
+
flows(e).update(row.id, { definition: def, enabled: c.body.enabled ?? true, latest_version: 1, active_version: c.body.activate === false ? null : 1 });
|
|
243
|
+
audit(e, 'sign_in_journey.created', 'sign_in_journey', row.id, { version: 1 });
|
|
244
|
+
return { status: 201, body: strip(flows(e).get(row.id)) };
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
method: 'PATCH',
|
|
249
|
+
pattern: '/v1/flows/:id',
|
|
250
|
+
scope: 'actions:write',
|
|
251
|
+
handler: (c) => {
|
|
252
|
+
const e = c.engine;
|
|
253
|
+
const before = flowOf(e, c.params.id);
|
|
254
|
+
const list = versionsOf(e, before);
|
|
255
|
+
(0, generic_1.genericHandler)(c);
|
|
256
|
+
const row = flows(e).get(before.id);
|
|
257
|
+
let { latest_version: latest, active_version: active } = row;
|
|
258
|
+
if (c.body.definition && typeof c.body.definition === 'object') {
|
|
259
|
+
list.push({ version: (latest = (latest ?? list.length) + 1), definition: c.body.definition, created_at: e.clock.now() });
|
|
260
|
+
if (c.body.activate !== false)
|
|
261
|
+
active = latest;
|
|
262
|
+
}
|
|
263
|
+
flows(e).update(row.id, { latest_version: latest, active_version: active, ...(typeof c.body.enabled === 'boolean' ? { enabled: c.body.enabled } : {}) });
|
|
264
|
+
flows(e).update(row.id, { definition: activeDefinition(e, flows(e).get(row.id)) });
|
|
265
|
+
audit(e, 'sign_in_journey.updated', 'sign_in_journey', row.id, { fields: Object.keys(c.body), version: latest });
|
|
266
|
+
return { body: strip(flows(e).get(row.id)) };
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
method: 'GET',
|
|
271
|
+
pattern: '/v1/flows/:id/versions',
|
|
272
|
+
scope: 'actions:read',
|
|
273
|
+
handler: (c) => ({ body: { object: 'list', data: [...versionsOf(c.engine, flowOf(c.engine, c.params.id))].reverse().map(versionView), has_more: false } }),
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
method: 'POST',
|
|
277
|
+
pattern: '/v1/flows/:id/activate',
|
|
278
|
+
scope: 'actions:write',
|
|
279
|
+
handler: (c) => {
|
|
280
|
+
const e = c.engine;
|
|
281
|
+
const flow = flowOf(e, c.params.id);
|
|
282
|
+
const version = c.body.version;
|
|
283
|
+
if (typeof version !== 'number' || !Number.isInteger(version))
|
|
284
|
+
throw bad('version must be an integer.', 'version');
|
|
285
|
+
const v = versionsOf(e, flow).find((x) => x.version === version);
|
|
286
|
+
if (!v)
|
|
287
|
+
throw new errors_1.EmulatorError('NOT_FOUND', 'Unknown journey version.');
|
|
288
|
+
flows(e).update(flow.id, { active_version: version, definition: v.definition });
|
|
289
|
+
audit(e, 'sign_in_journey.activated', 'sign_in_journey', flow.id, { version });
|
|
290
|
+
return { body: withDef(e, flows(e).get(flow.id)) };
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
// ── experiments ──
|
|
294
|
+
{ method: 'GET', pattern: '/v1/flows/:id/experiments', scope: 'actions:read', handler: (c) => ({ body: { object: 'list', data: st(c.engine).experiments.filter((x) => x.journey_id === flowOf(c.engine, c.params.id).id).map(experimentView), has_more: false } }) },
|
|
295
|
+
{
|
|
296
|
+
method: 'POST',
|
|
297
|
+
pattern: '/v1/flows/:id/experiments',
|
|
298
|
+
scope: 'actions:write',
|
|
299
|
+
handler: (c) => {
|
|
300
|
+
const e = c.engine;
|
|
301
|
+
const flow = flowOf(e, c.params.id);
|
|
302
|
+
const b = parseExperimentBody(e, flow, c.body, 'create');
|
|
303
|
+
if (st(e).experiments.filter((x) => x.journey_id === flow.id).length >= MAX_EXPERIMENTS_PER_JOURNEY)
|
|
304
|
+
throw bad(`At most ${MAX_EXPERIMENTS_PER_JOURNEY} experiments per journey; delete old ones.`, 'name');
|
|
305
|
+
if (st(e).experiments.find((x) => x.journey_id === flow.id && x.name === b.name))
|
|
306
|
+
throw bad(`An experiment named "${b.name}" already exists on this journey.`, 'name');
|
|
307
|
+
const now = e.clock.now();
|
|
308
|
+
const x = st(e).experiments.insert({ journey_id: flow.id, name: b.name, hypothesis: b.hypothesis ?? null, status: 'draft', variants: b.variants, promoted_variant: null, started_at: null, stopped_at: null, created_at: now, updated_at: now });
|
|
309
|
+
audit(e, 'journey_experiment.created', 'journey_experiment', x.id, { journey_id: flow.id });
|
|
310
|
+
return { status: 201, body: experimentView(x) };
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
{ method: 'GET', pattern: '/v1/flows/:id/experiments/:experimentId', scope: 'actions:read', handler: (c) => ({ body: experimentView(exp(c).x) }) },
|
|
314
|
+
{
|
|
315
|
+
method: 'PATCH',
|
|
316
|
+
pattern: '/v1/flows/:id/experiments/:experimentId',
|
|
317
|
+
scope: 'actions:write',
|
|
318
|
+
handler: (c) => {
|
|
319
|
+
const e = c.engine;
|
|
320
|
+
const { flow, x } = exp(c);
|
|
321
|
+
if (x.status !== 'draft')
|
|
322
|
+
throw conflict('Only a draft experiment can be changed; a started experiment keeps its variants so nobody moves.');
|
|
323
|
+
const b = parseExperimentBody(e, flow, c.body, 'update');
|
|
324
|
+
if (b.name !== undefined && st(e).experiments.find((y) => y.journey_id === flow.id && y.name === b.name && y.id !== x.id))
|
|
325
|
+
throw bad(`An experiment named "${b.name}" already exists on this journey.`, 'name');
|
|
326
|
+
Object.assign(x, b, { updated_at: e.clock.now() });
|
|
327
|
+
audit(e, 'journey_experiment.updated', 'journey_experiment', x.id, { journey_id: flow.id, fields: Object.keys(c.body) });
|
|
328
|
+
return { body: experimentView(x) };
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
method: 'DELETE',
|
|
333
|
+
pattern: '/v1/flows/:id/experiments/:experimentId',
|
|
334
|
+
scope: 'actions:write',
|
|
335
|
+
handler: (c) => {
|
|
336
|
+
const e = c.engine;
|
|
337
|
+
const { flow, x } = exp(c);
|
|
338
|
+
if (x.status === 'running')
|
|
339
|
+
throw conflict('Stop a running experiment before deleting it.');
|
|
340
|
+
st(e).experiments.delete(x.id);
|
|
341
|
+
for (const k of [...st(e).assignments.keys()])
|
|
342
|
+
if (k.startsWith(`${x.id}|`))
|
|
343
|
+
st(e).assignments.delete(k);
|
|
344
|
+
audit(e, 'journey_experiment.deleted', 'journey_experiment', x.id, { journey_id: flow.id });
|
|
345
|
+
return { body: { object: 'journey_experiment', id: x.id, deleted: true } };
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
method: 'POST',
|
|
350
|
+
pattern: '/v1/flows/:id/experiments/:experimentId/start',
|
|
351
|
+
scope: 'actions:write',
|
|
352
|
+
handler: (c) => {
|
|
353
|
+
const e = c.engine;
|
|
354
|
+
const { flow, x } = exp(c);
|
|
355
|
+
if (x.status !== 'draft')
|
|
356
|
+
throw conflict('Only a draft experiment can be started.');
|
|
357
|
+
if (flow.enabled === false || flow.active_version == null)
|
|
358
|
+
throw conflict('The journey must be enabled with an active version before an experiment can run on it.');
|
|
359
|
+
const have = new Set(versionsOf(e, flow).map((v) => v.version));
|
|
360
|
+
const problem = (0, emu4_journey_math_1.validateVariants)(x.variants, (n) => have.has(n));
|
|
361
|
+
if (problem)
|
|
362
|
+
throw bad(problem.message, problem.path);
|
|
363
|
+
if (runningOn(e, flow.id))
|
|
364
|
+
throw conflict('Another experiment is already running on this journey. Stop it first.');
|
|
365
|
+
const now = e.clock.now();
|
|
366
|
+
Object.assign(x, { status: 'running', started_at: now, updated_at: now });
|
|
367
|
+
e.webhooks.emit('journey.experiment_started', { object: 'journey_experiment', experiment_id: x.id, journey_id: flow.id, variants: x.variants, at: now });
|
|
368
|
+
audit(e, 'journey_experiment.started', 'journey_experiment', x.id, { journey_id: flow.id });
|
|
369
|
+
return { body: experimentView(x) };
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
method: 'POST',
|
|
374
|
+
pattern: '/v1/flows/:id/experiments/:experimentId/stop',
|
|
375
|
+
scope: 'actions:write',
|
|
376
|
+
handler: (c) => {
|
|
377
|
+
const e = c.engine;
|
|
378
|
+
const { flow, x } = exp(c);
|
|
379
|
+
if (x.status !== 'running')
|
|
380
|
+
throw conflict('Only a running experiment can be stopped.');
|
|
381
|
+
const promote = c.body.promote_variant;
|
|
382
|
+
let promoted;
|
|
383
|
+
if (promote !== undefined && promote !== null) {
|
|
384
|
+
if (typeof promote !== 'string')
|
|
385
|
+
throw bad('promote_variant must be a variant key.', 'promote_variant');
|
|
386
|
+
promoted = x.variants.find((v) => v.key === promote);
|
|
387
|
+
if (!promoted)
|
|
388
|
+
throw bad(`Unknown variant "${promote}".`, 'promote_variant');
|
|
389
|
+
}
|
|
390
|
+
const now = e.clock.now();
|
|
391
|
+
Object.assign(x, { status: 'stopped', stopped_at: now, promoted_variant: promoted?.key ?? null, updated_at: now });
|
|
392
|
+
if (promoted) {
|
|
393
|
+
const v = versionsOf(e, flow).find((y) => y.version === promoted.version);
|
|
394
|
+
flows(e).update(flow.id, { active_version: v.version, definition: v.definition });
|
|
395
|
+
}
|
|
396
|
+
e.webhooks.emit('journey.experiment_stopped', { object: 'journey_experiment', experiment_id: x.id, journey_id: flow.id, promoted_variant: promoted?.key ?? null, at: now });
|
|
397
|
+
audit(e, 'journey_experiment.stopped', 'journey_experiment', x.id, { journey_id: flow.id, promoted_variant: promoted?.key ?? null });
|
|
398
|
+
return { body: experimentView(x) };
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
{ method: 'GET', pattern: '/v1/flows/:id/experiments/:experimentId/readout', scope: 'actions:read', handler: (c) => ({ body: readout(c.engine, exp(c).x) }) },
|
|
402
|
+
{
|
|
403
|
+
// Emulator: where a user's bucket falls and which variant it picks, without assigning anybody.
|
|
404
|
+
method: 'GET',
|
|
405
|
+
pattern: '/__emulator/journeys/:id/experiments/:experimentId/bucket',
|
|
406
|
+
public: true,
|
|
407
|
+
handler: async (c) => {
|
|
408
|
+
const { x } = exp(c);
|
|
409
|
+
const userId = c.query.get('user_id');
|
|
410
|
+
if (!userId)
|
|
411
|
+
throw bad('user_id is required.', 'user_id');
|
|
412
|
+
const bucket = await (0, emu4_journey_math_1.experimentBucket)(x.id, userId);
|
|
413
|
+
const stored = st(c.engine).assignments.get(`${x.id}|${userId}`);
|
|
414
|
+
return { body: { object: 'journey_experiment_bucket', experiment_id: x.id, user_id: userId, bucket, variant: (0, emu4_journey_math_1.variantForBucket)(x.variants, bucket).key, assigned: stored ? stored.variant : null } };
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
// ── scheduled messages ──
|
|
418
|
+
{
|
|
419
|
+
method: 'GET',
|
|
420
|
+
pattern: '/v1/flows/:id/messages',
|
|
421
|
+
scope: 'actions:read',
|
|
422
|
+
handler: (c) => {
|
|
423
|
+
const e = c.engine;
|
|
424
|
+
const flow = flowOf(e, c.params.id);
|
|
425
|
+
const status = c.query.get('status');
|
|
426
|
+
const userId = c.query.get('user_id');
|
|
427
|
+
if (status !== null && !MESSAGE_STATUSES.includes(status))
|
|
428
|
+
throw bad(`status must be one of ${MESSAGE_STATUSES.join(', ')}.`, 'status');
|
|
429
|
+
deliverDueJourneyMessages(e);
|
|
430
|
+
const limit = Math.min(Math.max(Number(c.query.get('limit')) || 50, 1), 200);
|
|
431
|
+
const rows = st(e).messages.filter((m) => m.journey_id === flow.id && (!status || m.status === status) && (!userId || m.user_id === userId)).slice(0, limit);
|
|
432
|
+
return { body: { object: 'list', data: rows.map(messageView), has_more: false } };
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
method: 'POST',
|
|
437
|
+
pattern: '/v1/flows/:id/messages/:messageId/cancel',
|
|
438
|
+
scope: 'actions:write',
|
|
439
|
+
handler: (c) => {
|
|
440
|
+
const e = c.engine;
|
|
441
|
+
const flow = flowOf(e, c.params.id);
|
|
442
|
+
const m = st(e).messages.get(c.params.messageId);
|
|
443
|
+
if (!m || m.journey_id !== flow.id)
|
|
444
|
+
throw new errors_1.EmulatorError('NOT_FOUND', 'Unknown message.');
|
|
445
|
+
if (m.status !== 'pending' && m.status !== 'failed')
|
|
446
|
+
throw conflict('Only a message that has not gone out (pending or failed) can be canceled.');
|
|
447
|
+
m.status = 'canceled';
|
|
448
|
+
audit(e, 'journey_message.canceled', 'journey_message', m.id, { journey_id: flow.id, user_id: m.user_id, step: m.step });
|
|
449
|
+
return { body: messageView(m) };
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
// Emulator: the worker's delivery loop. It also runs when the clock is advanced and whenever messages are listed.
|
|
454
|
+
method: 'POST',
|
|
455
|
+
pattern: '/__emulator/journeys/messages/deliver',
|
|
456
|
+
public: true,
|
|
457
|
+
handler: (c) => ({ body: { object: 'journey_message_sweep', ...deliverDueJourneyMessages(c.engine) } }),
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
// Emulator: schedule messages for a user without finishing a journey (the real API schedules them when a journey completes).
|
|
461
|
+
method: 'POST',
|
|
462
|
+
pattern: '/__emulator/journeys/:id/messages',
|
|
463
|
+
public: true,
|
|
464
|
+
handler: (c) => {
|
|
465
|
+
const e = c.engine;
|
|
466
|
+
const flow = flowOf(e, c.params.id);
|
|
467
|
+
if (typeof c.body.user_id !== 'string' || !e.db.users.get(c.body.user_id))
|
|
468
|
+
throw (0, errors_1.notFound)('User');
|
|
469
|
+
const list = c.body.messages;
|
|
470
|
+
if (!Array.isArray(list) || !list.length || list.length > 20)
|
|
471
|
+
throw bad('messages is a list of 1 to 20 messages.', 'messages');
|
|
472
|
+
const messages = list.map((m, i) => {
|
|
473
|
+
if (typeof m?.step !== 'string' || !m.step)
|
|
474
|
+
throw bad('step is required.', `messages.${i}.step`);
|
|
475
|
+
if (typeof m.subject !== 'string' || !m.subject.trim() || m.subject.length > 150)
|
|
476
|
+
throw bad('subject is required (at most 150 characters).', `messages.${i}.subject`);
|
|
477
|
+
if (typeof m.body !== 'string' || !m.body.trim() || m.body.length > 5000)
|
|
478
|
+
throw bad('body is required (at most 5000 characters).', `messages.${i}.body`);
|
|
479
|
+
if (m.sms_body !== undefined && (typeof m.sms_body !== 'string' || !m.sms_body.trim() || m.sms_body.length > 320))
|
|
480
|
+
throw bad('sms_body: text of at most 320 characters.', `messages.${i}.sms_body`);
|
|
481
|
+
const d = m.delay_seconds ?? 0;
|
|
482
|
+
if (!Number.isInteger(d) || d < 0 || d > MAX_DELAY_SECONDS)
|
|
483
|
+
throw bad(`delay_seconds: a whole number from 0 to ${MAX_DELAY_SECONDS}.`, `messages.${i}.delay_seconds`);
|
|
484
|
+
return { step: m.step, subject: m.subject, body: m.body, delaySeconds: d, ...(m.sms_body ? { smsBody: m.sms_body } : {}) };
|
|
485
|
+
});
|
|
486
|
+
const added = schedule(e, { journey_id: flow.id, user_id: c.body.user_id, messages });
|
|
487
|
+
return { status: 201, body: { object: 'journey_message_schedule', journey_id: flow.id, user_id: c.body.user_id, scheduled: added } };
|
|
488
|
+
},
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
// Emulator: users have no phone numbers of their own in this engine; this gives one to text.
|
|
492
|
+
method: 'PUT',
|
|
493
|
+
pattern: '/__emulator/users/:id/phone_number',
|
|
494
|
+
public: true,
|
|
495
|
+
handler: (c) => {
|
|
496
|
+
const u = c.engine.db.users.get(c.params.id);
|
|
497
|
+
if (!u || u.deleted)
|
|
498
|
+
throw (0, errors_1.notFound)('User');
|
|
499
|
+
if (typeof c.body.phone_number !== 'string' || !/^\+[1-9]\d{6,14}$/.test(c.body.phone_number))
|
|
500
|
+
throw bad('phone_number must be an E.164 number such as +4915112345678.', 'phone_number');
|
|
501
|
+
u.phone = { phone_number: c.body.phone_number, verified: c.body.verified !== false };
|
|
502
|
+
return { body: { object: 'phone_number', user_id: u.id, ...u.phone } };
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
{ method: 'GET', pattern: '/__emulator/messaging/audit', public: true, handler: (c) => (0, util_1.listReply)(c, [...st(c.engine).audit].reverse(), (a) => ({ object: 'audit_log_entry', ...a })) },
|
|
506
|
+
// ── Amazon SNS certificates ──
|
|
507
|
+
{
|
|
508
|
+
// The test certificate and key: sign your own SNS messages with the private key and point SigningCertURL at signing_cert_url.
|
|
509
|
+
method: 'GET',
|
|
510
|
+
pattern: '/__emulator/messaging/sns',
|
|
511
|
+
public: true,
|
|
512
|
+
handler: async (c) => {
|
|
513
|
+
const k = await (0, messaging_1.keyStore)(c.engine).rsa();
|
|
514
|
+
const s = (0, emu4_sns_1.snsState)(c.engine);
|
|
515
|
+
return { body: { object: 'sns_signing_certificate', signing_cert_url: messaging_callbacks_1.SNS_CERT_URL, certificate_pem: k.certPem, private_key_pem: pemOf(k.pkcs8), public_key: k.publicB64, signature_versions: ['1', '2'], extra_certificates: [...s.certs.keys()], subscription_confirmations: s.confirmations } };
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
// What the built-in SigningCertURL serves, as a real certificate file.
|
|
520
|
+
method: 'GET',
|
|
521
|
+
pattern: '/__emulator/messaging/sns/SimpleNotificationService-emulator.pem',
|
|
522
|
+
public: true,
|
|
523
|
+
handler: async (c) => ({ raw: (await (0, messaging_1.keyStore)(c.engine).rsa()).certPem, headers: { 'content-type': 'application/x-pem-file' } }),
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
// Serve a certificate of your own at a URL SNS could use, so a message signed with your key verifies.
|
|
527
|
+
method: 'PUT',
|
|
528
|
+
pattern: '/__emulator/messaging/sns/certificates',
|
|
529
|
+
public: true,
|
|
530
|
+
handler: (c) => {
|
|
531
|
+
const url = c.body.url;
|
|
532
|
+
let host = null;
|
|
533
|
+
try {
|
|
534
|
+
host = typeof url === 'string' ? new URL(url) : null;
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
host = null;
|
|
538
|
+
}
|
|
539
|
+
if (!host || host.protocol !== 'https:' || !SNS_HOST.test(host.hostname) || host.username || host.password || (host.port && host.port !== '443'))
|
|
540
|
+
throw bad('url must be an https URL on an sns.<region>.amazonaws.com host, as SNS signing certificates are.', 'url');
|
|
541
|
+
if (typeof c.body.certificate_pem !== 'string' || !(0, sns_cert_1.fromPem)(c.body.certificate_pem, 'CERTIFICATE') || !(0, sns_cert_1.certificatePublicKey)(c.body.certificate_pem))
|
|
542
|
+
throw bad('certificate_pem must be an X.509 certificate in PEM form.', 'certificate_pem');
|
|
543
|
+
(0, emu4_sns_1.snsState)(c.engine).certs.set(host.href, c.body.certificate_pem);
|
|
544
|
+
return { status: 201, body: { object: 'sns_certificate', url: host.href } };
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
method: 'DELETE',
|
|
549
|
+
pattern: '/__emulator/messaging/sns/certificates',
|
|
550
|
+
public: true,
|
|
551
|
+
handler: (c) => {
|
|
552
|
+
const url = c.query.get('url');
|
|
553
|
+
if (!url || !(0, emu4_sns_1.snsState)(c.engine).certs.delete(url))
|
|
554
|
+
throw (0, errors_1.notFound)('Certificate');
|
|
555
|
+
return { body: { object: 'sns_certificate', url, deleted: true } };
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
// Build an SNS message signed with the test key (a Notification, or a SubscriptionConfirmation to see the confirmation URL "visited").
|
|
560
|
+
method: 'POST',
|
|
561
|
+
pattern: '/__emulator/messaging/sns/sign',
|
|
562
|
+
public: true,
|
|
563
|
+
handler: async (c) => {
|
|
564
|
+
const e = c.engine;
|
|
565
|
+
const b = c.body;
|
|
566
|
+
const type = b.type === undefined ? 'Notification' : b.type;
|
|
567
|
+
if (type !== 'Notification' && type !== 'SubscriptionConfirmation')
|
|
568
|
+
throw bad('type must be Notification or SubscriptionConfirmation.', 'type');
|
|
569
|
+
const version = b.signature_version === undefined ? '2' : String(b.signature_version);
|
|
570
|
+
if (version !== '1' && version !== '2')
|
|
571
|
+
throw bad('signature_version must be 1 or 2.', 'signature_version');
|
|
572
|
+
if (b.message !== undefined && typeof b.message !== 'string')
|
|
573
|
+
throw bad('message must be a string (the SES notification JSON).', 'message');
|
|
574
|
+
const certUrl = typeof b.signing_cert_url === 'string' ? b.signing_cert_url : messaging_callbacks_1.SNS_CERT_URL;
|
|
575
|
+
const topic = typeof b.topic_arn === 'string' ? b.topic_arn : 'arn:aws:sns:us-east-1:123456789012:atlas-emulator';
|
|
576
|
+
const iso = new Date(e.clock.now()).toISOString();
|
|
577
|
+
const m = { Type: type, MessageId: `sns_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`, TopicArn: topic, Message: typeof b.message === 'string' ? b.message : type === 'Notification' ? '{}' : 'You have chosen to subscribe to the topic.', Timestamp: iso, SignatureVersion: version, SigningCertURL: certUrl };
|
|
578
|
+
if (type === 'SubscriptionConfirmation') {
|
|
579
|
+
m.Token = 'emulator-subscription-token';
|
|
580
|
+
m.SubscribeURL = typeof b.subscribe_url === 'string' ? b.subscribe_url : `https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription&TopicArn=${encodeURIComponent(topic)}&Token=${m.Token}`;
|
|
581
|
+
}
|
|
582
|
+
const k = await (0, messaging_1.keyStore)(e).rsa();
|
|
583
|
+
const key = await crypto.subtle.importKey('pkcs8', k.pkcs8, { name: 'RSASSA-PKCS1-v1_5', hash: version === '2' ? 'SHA-256' : 'SHA-1' }, false, ['sign']);
|
|
584
|
+
const sig = new Uint8Array(await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, new TextEncoder().encode((0, messaging_callbacks_1.snsStringToSign)(m) + (b.tamper === true ? 'tampered' : ''))));
|
|
585
|
+
let bin = '';
|
|
586
|
+
for (const x of sig)
|
|
587
|
+
bin += String.fromCharCode(x);
|
|
588
|
+
m.Signature = btoa(bin);
|
|
589
|
+
const token = await (0, messaging_callbacks_1.receiverToken)(e.instanceId, 'ses');
|
|
590
|
+
return { body: { object: 'messaging_callback', provider: 'ses', method: 'POST', url: `${e.issuer}/v1/webhooks/ses/${e.instanceId}?t=${token}`, headers: { 'content-type': 'application/json' }, body: JSON.stringify(m), signed_with: 'signature' } };
|
|
591
|
+
},
|
|
592
|
+
},
|
|
593
|
+
];
|
|
594
|
+
}
|
|
595
|
+
//# sourceMappingURL=emu4-messaging.js.map
|