apphud-mcp 0.1.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/.env.example +23 -0
- package/CHANGELOG.md +21 -0
- package/CODE_OF_CONDUCT.md +27 -0
- package/CONTRIBUTING.md +73 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/SECURITY.md +25 -0
- package/SUPPORT.md +26 -0
- package/assets/apphud-mcp-logo.svg +6 -0
- package/dist/src/app.js +31 -0
- package/dist/src/cli.js +94 -0
- package/dist/src/config/env.js +249 -0
- package/dist/src/domain/constants.js +56 -0
- package/dist/src/domain/models.js +1 -0
- package/dist/src/errors/toolError.js +40 -0
- package/dist/src/http/server.js +24 -0
- package/dist/src/index.js +47 -0
- package/dist/src/mcp/server.js +435 -0
- package/dist/src/security/authResolver.js +43 -0
- package/dist/src/security/rateLimiter.js +22 -0
- package/dist/src/security/rbac.js +11 -0
- package/dist/src/security/secretStore.js +14 -0
- package/dist/src/services/analyticsService.js +934 -0
- package/dist/src/services/appService.js +15 -0
- package/dist/src/services/apphudClient.js +1632 -0
- package/dist/src/services/auditService.js +12 -0
- package/dist/src/services/toolGuard.js +30 -0
- package/package.json +61 -0
|
@@ -0,0 +1,1632 @@
|
|
|
1
|
+
import { ApphudMcpError, isApphudMcpError } from "../errors/toolError.js";
|
|
2
|
+
const METRIC_VALUE_FIELDS = ["value", "count", "total", "current", "metric_value", "result", "amount", "y"];
|
|
3
|
+
const DATE_CANDIDATE_FIELDS = [
|
|
4
|
+
"date",
|
|
5
|
+
"day",
|
|
6
|
+
"week",
|
|
7
|
+
"month",
|
|
8
|
+
"period",
|
|
9
|
+
"at",
|
|
10
|
+
"time",
|
|
11
|
+
"timestamp",
|
|
12
|
+
"occurred_at",
|
|
13
|
+
"x",
|
|
14
|
+
"label",
|
|
15
|
+
];
|
|
16
|
+
const BREAKDOWN_KEY_FIELDS = [
|
|
17
|
+
"key",
|
|
18
|
+
"name",
|
|
19
|
+
"label",
|
|
20
|
+
"category",
|
|
21
|
+
"group",
|
|
22
|
+
"dimension",
|
|
23
|
+
"country",
|
|
24
|
+
"platform",
|
|
25
|
+
"product",
|
|
26
|
+
"product_id",
|
|
27
|
+
"x",
|
|
28
|
+
];
|
|
29
|
+
const EVENT_ID_KEYS = ["event_id", "id", "uuid"];
|
|
30
|
+
const EVENT_TYPE_KEYS = ["event_type", "event", "type", "name", "kind", "action"];
|
|
31
|
+
const EVENT_USER_ID_KEYS = ["user_id", "userId", "customer_user_id", "customer_id", "uid", "subscriber_id"];
|
|
32
|
+
const EVENT_PRODUCT_ID_KEYS = ["product_id", "productId", "sku", "subscription_product_id"];
|
|
33
|
+
const EVENT_ORIGINAL_TRANSACTION_ID_KEYS = [
|
|
34
|
+
"original_transaction_id",
|
|
35
|
+
"originalTransactionId",
|
|
36
|
+
"original_tx_id",
|
|
37
|
+
"transaction_id",
|
|
38
|
+
];
|
|
39
|
+
const EVENT_SUBSCRIPTION_STATUS_KEYS = ["subscription_status", "status", "subscriptionState"];
|
|
40
|
+
const EVENT_COUNTRY_KEYS = ["country", "country_code", "countryCode", "storefront"];
|
|
41
|
+
const EVENT_PLATFORM_KEYS = ["platform", "os", "store"];
|
|
42
|
+
const EVENT_PRICE_KEYS = ["price", "amount", "revenue", "proceeds", "value"];
|
|
43
|
+
const EVENT_CURRENCY_KEYS = ["currency", "currency_code", "currencyCode"];
|
|
44
|
+
const METRIC_KEY_ALIASES = {
|
|
45
|
+
active_subs: [
|
|
46
|
+
"active_subs",
|
|
47
|
+
"active_subscription",
|
|
48
|
+
"active_subscriptions",
|
|
49
|
+
"active_paid_subs",
|
|
50
|
+
"active_paid_subscription",
|
|
51
|
+
"active_paid_subscriptions",
|
|
52
|
+
],
|
|
53
|
+
active_trials: ["active_trials", "active_trial", "trials_active"],
|
|
54
|
+
revenue_gross: ["revenue", "gross_revenue", "revenue_gross", "sales", "proceeds"],
|
|
55
|
+
refunds: ["refunds", "refunded", "refund_amount"],
|
|
56
|
+
trials_started: ["trials_started", "trial_started", "trials"],
|
|
57
|
+
trials_converted: ["trials_converted", "trial_converted", "trial_to_paid"],
|
|
58
|
+
new_subscriptions: ["new_subscriptions", "subscription_started", "subscriptions_started", "new_subs"],
|
|
59
|
+
renewals: ["renewals", "subscription_renewals"],
|
|
60
|
+
cancellations: ["cancellations", "canceled", "expires", "expirations"],
|
|
61
|
+
subscribers_retention: ["subscribers_retention", "retention", "subscription_retention"],
|
|
62
|
+
cumulative_ltv: ["cumulative_ltv", "ltv", "lifetime_value"],
|
|
63
|
+
};
|
|
64
|
+
function asRecord(value) {
|
|
65
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
function isRecord(value) {
|
|
71
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
72
|
+
}
|
|
73
|
+
function asArray(value) {
|
|
74
|
+
if (!Array.isArray(value)) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
return value.filter((item) => !!item && typeof item === "object");
|
|
78
|
+
}
|
|
79
|
+
function readStringValue(value) {
|
|
80
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
81
|
+
return value.trim();
|
|
82
|
+
}
|
|
83
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
84
|
+
return String(value);
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
function readRecordString(record, keys) {
|
|
89
|
+
for (const key of keys) {
|
|
90
|
+
const value = readStringValue(record[key]);
|
|
91
|
+
if (value) {
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
function readBooleanValue(value) {
|
|
98
|
+
if (typeof value === "boolean") {
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
102
|
+
if (value === 1) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
if (value === 0) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
if (typeof value === "string") {
|
|
111
|
+
const normalized = value.trim().toLowerCase();
|
|
112
|
+
if (["1", "true", "yes", "y"].includes(normalized)) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
if (["0", "false", "no", "n"].includes(normalized)) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
function extractDashboardApps(payload) {
|
|
122
|
+
const idKeys = ["app_id", "appId", "id", "apple_id", "bundle_id", "bundleId", "slug"];
|
|
123
|
+
const nameKeys = ["app_name", "appName", "name", "title", "display_name", "bundle_id", "bundleId"];
|
|
124
|
+
const platformKeys = ["platform", "store", "os"];
|
|
125
|
+
const apps = new Map();
|
|
126
|
+
const visited = new WeakSet();
|
|
127
|
+
const maybeAddApp = (record) => {
|
|
128
|
+
let appId;
|
|
129
|
+
let appName;
|
|
130
|
+
let platform;
|
|
131
|
+
for (const key of idKeys) {
|
|
132
|
+
appId = readStringValue(record[key]);
|
|
133
|
+
if (appId) {
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (const key of nameKeys) {
|
|
138
|
+
appName = readStringValue(record[key]);
|
|
139
|
+
if (appName) {
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
for (const key of platformKeys) {
|
|
144
|
+
platform = readStringValue(record[key]);
|
|
145
|
+
if (platform) {
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!appId) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const normalizedId = appId.trim();
|
|
153
|
+
if (!normalizedId) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!apps.has(normalizedId)) {
|
|
157
|
+
apps.set(normalizedId, {
|
|
158
|
+
app_id: normalizedId,
|
|
159
|
+
app_name: appName ?? normalizedId,
|
|
160
|
+
platform,
|
|
161
|
+
});
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const existing = apps.get(normalizedId);
|
|
165
|
+
if (!existing) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (existing.app_name === normalizedId && appName) {
|
|
169
|
+
existing.app_name = appName;
|
|
170
|
+
}
|
|
171
|
+
if (!existing.platform && platform) {
|
|
172
|
+
existing.platform = platform;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
const walk = (node) => {
|
|
176
|
+
if (Array.isArray(node)) {
|
|
177
|
+
for (const item of node) {
|
|
178
|
+
walk(item);
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (!isRecord(node)) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (visited.has(node)) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
visited.add(node);
|
|
189
|
+
maybeAddApp(node);
|
|
190
|
+
for (const value of Object.values(node)) {
|
|
191
|
+
walk(value);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
walk(payload);
|
|
195
|
+
return Array.from(apps.values()).sort((left, right) => left.app_name.localeCompare(right.app_name));
|
|
196
|
+
}
|
|
197
|
+
function normalizeCustomer(userId, payload) {
|
|
198
|
+
const root = asRecord(payload.results ?? payload.result ?? payload.customer ?? payload);
|
|
199
|
+
const subscriptions = asArray(root.subscriptions ?? payload.subscriptions);
|
|
200
|
+
const purchases = asArray(root.purchases ?? payload.purchases);
|
|
201
|
+
const activeSubscription = subscriptions.find((subscription) => {
|
|
202
|
+
const status = String(subscription.status ?? "").toLowerCase();
|
|
203
|
+
return status === "active" || status === "trial" || status === "in_trial";
|
|
204
|
+
});
|
|
205
|
+
const isPremiumField = root.is_premium;
|
|
206
|
+
const isPremium = typeof isPremiumField === "boolean"
|
|
207
|
+
? isPremiumField
|
|
208
|
+
: Boolean(activeSubscription || root.has_active_subscription === true);
|
|
209
|
+
const inTrial = Boolean(root.in_trial === true ||
|
|
210
|
+
root.is_in_trial === true ||
|
|
211
|
+
String(activeSubscription?.status ?? "").toLowerCase().includes("trial"));
|
|
212
|
+
const activeProductId = typeof activeSubscription?.product_id === "string"
|
|
213
|
+
? activeSubscription.product_id
|
|
214
|
+
: typeof root.active_product_id === "string"
|
|
215
|
+
? root.active_product_id
|
|
216
|
+
: undefined;
|
|
217
|
+
const expiresAt = typeof activeSubscription?.expires_at === "string"
|
|
218
|
+
? activeSubscription.expires_at
|
|
219
|
+
: typeof root.expires_at === "string"
|
|
220
|
+
? root.expires_at
|
|
221
|
+
: undefined;
|
|
222
|
+
return {
|
|
223
|
+
user_id: userId,
|
|
224
|
+
is_premium: isPremium,
|
|
225
|
+
active_product_id: activeProductId,
|
|
226
|
+
expires_at: expiresAt,
|
|
227
|
+
in_trial: inTrial,
|
|
228
|
+
subscriptions: subscriptions.map((subscription) => ({
|
|
229
|
+
product_id: typeof subscription.product_id === "string" ? subscription.product_id : undefined,
|
|
230
|
+
status: typeof subscription.status === "string" ? subscription.status : undefined,
|
|
231
|
+
expires_at: typeof subscription.expires_at === "string" ? subscription.expires_at : undefined,
|
|
232
|
+
is_in_retry: typeof subscription.is_in_retry === "boolean" ? subscription.is_in_retry : undefined,
|
|
233
|
+
})),
|
|
234
|
+
purchases: purchases.map((purchase) => ({
|
|
235
|
+
product_id: typeof purchase.product_id === "string" ? purchase.product_id : undefined,
|
|
236
|
+
purchased_at: typeof purchase.purchased_at === "string" ? purchase.purchased_at : undefined,
|
|
237
|
+
price: typeof purchase.price === "number" ? purchase.price : undefined,
|
|
238
|
+
currency: typeof purchase.currency === "string" ? purchase.currency : undefined,
|
|
239
|
+
})),
|
|
240
|
+
raw_source: "apphud_customers_api",
|
|
241
|
+
snapshot_at: new Date().toISOString(),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
function normalizeMetricKey(value) {
|
|
245
|
+
return value
|
|
246
|
+
.trim()
|
|
247
|
+
.toLowerCase()
|
|
248
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
249
|
+
.replace(/^_+|_+$/g, "");
|
|
250
|
+
}
|
|
251
|
+
function expandMetricAliases(metricKey) {
|
|
252
|
+
const normalized = normalizeMetricKey(metricKey);
|
|
253
|
+
const aliases = new Set([normalized]);
|
|
254
|
+
const canonicalAliases = METRIC_KEY_ALIASES[normalized] ?? [];
|
|
255
|
+
for (const alias of canonicalAliases) {
|
|
256
|
+
aliases.add(normalizeMetricKey(alias));
|
|
257
|
+
}
|
|
258
|
+
for (const [canonical, list] of Object.entries(METRIC_KEY_ALIASES)) {
|
|
259
|
+
if (list.map(normalizeMetricKey).includes(normalized)) {
|
|
260
|
+
aliases.add(canonical);
|
|
261
|
+
for (const alias of list) {
|
|
262
|
+
aliases.add(normalizeMetricKey(alias));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return aliases;
|
|
267
|
+
}
|
|
268
|
+
function parseNumericValue(value) {
|
|
269
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
270
|
+
return value;
|
|
271
|
+
}
|
|
272
|
+
if (typeof value !== "string") {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
const sanitized = value.replace(/[,_\s]/g, "");
|
|
276
|
+
if (sanitized.length === 0) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
const parsed = Number(sanitized);
|
|
280
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
281
|
+
}
|
|
282
|
+
function readMetricValueFromRecord(record, aliases) {
|
|
283
|
+
if (aliases) {
|
|
284
|
+
for (const [key, value] of Object.entries(record)) {
|
|
285
|
+
if (!aliases.has(normalizeMetricKey(key))) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (isRecord(value)) {
|
|
289
|
+
const nested = readMetricValueFromRecord(value);
|
|
290
|
+
if (nested !== null) {
|
|
291
|
+
return nested;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const numeric = parseNumericValue(value);
|
|
295
|
+
if (numeric !== null) {
|
|
296
|
+
return numeric;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
for (const field of METRIC_VALUE_FIELDS) {
|
|
301
|
+
const numeric = parseNumericValue(record[field]);
|
|
302
|
+
if (numeric !== null) {
|
|
303
|
+
return numeric;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
function parseDateValue(value) {
|
|
309
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
310
|
+
const parsed = new Date(value);
|
|
311
|
+
return Number.isNaN(parsed.getTime()) ? value : parsed.toISOString();
|
|
312
|
+
}
|
|
313
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
314
|
+
const timestampMs = value > 1_000_000_000_000 ? value : value * 1000;
|
|
315
|
+
const parsed = new Date(timestampMs);
|
|
316
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
function extractDateFromRecord(record) {
|
|
321
|
+
for (const field of DATE_CANDIDATE_FIELDS) {
|
|
322
|
+
const parsed = parseDateValue(record[field]);
|
|
323
|
+
if (parsed) {
|
|
324
|
+
return parsed;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
for (const [key, value] of Object.entries(record)) {
|
|
328
|
+
if (!/(date|time|day|week|month|period|at|timestamp)/i.test(key)) {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const parsed = parseDateValue(value);
|
|
332
|
+
if (parsed) {
|
|
333
|
+
return parsed;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
function normalizeEventType(value) {
|
|
339
|
+
if (!value) {
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
const normalized = value
|
|
343
|
+
.trim()
|
|
344
|
+
.toLowerCase()
|
|
345
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
346
|
+
.replace(/^_+|_+$/g, "");
|
|
347
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
348
|
+
}
|
|
349
|
+
function normalizeCurrency(value) {
|
|
350
|
+
if (!value) {
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
const normalized = value.trim().toUpperCase();
|
|
354
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
355
|
+
}
|
|
356
|
+
function normalizePlatform(value) {
|
|
357
|
+
if (!value) {
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
const normalized = value.trim().toLowerCase();
|
|
361
|
+
if (normalized.includes("ios") || normalized.includes("apple")) {
|
|
362
|
+
return "ios";
|
|
363
|
+
}
|
|
364
|
+
if (normalized.includes("android") || normalized.includes("google") || normalized.includes("play")) {
|
|
365
|
+
return "android";
|
|
366
|
+
}
|
|
367
|
+
if (normalized.includes("web")) {
|
|
368
|
+
return "web";
|
|
369
|
+
}
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
function extractBreakdownKeyFromRecord(record) {
|
|
373
|
+
for (const field of BREAKDOWN_KEY_FIELDS) {
|
|
374
|
+
const value = record[field];
|
|
375
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
376
|
+
return value;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
function metricNamedKey(record) {
|
|
382
|
+
return normalizeMetricKey(String(record.metric ?? record.key ?? record.id ?? record.name ?? record.code ?? record.slug ?? ""));
|
|
383
|
+
}
|
|
384
|
+
function isLikelyMetricIdentifier(value) {
|
|
385
|
+
return /[a-z]/.test(value) && value.length >= 3 && value.length <= 80;
|
|
386
|
+
}
|
|
387
|
+
export function extractMetricValue(payload, metricKey) {
|
|
388
|
+
const aliases = expandMetricAliases(metricKey);
|
|
389
|
+
const visited = new WeakSet();
|
|
390
|
+
const search = (node, path) => {
|
|
391
|
+
if (Array.isArray(node)) {
|
|
392
|
+
for (let index = 0; index < node.length; index += 1) {
|
|
393
|
+
const found = search(node[index], `${path}[${index}]`);
|
|
394
|
+
if (found) {
|
|
395
|
+
return found;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
if (!isRecord(node)) {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
if (visited.has(node)) {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
visited.add(node);
|
|
407
|
+
const namedMetric = metricNamedKey(node);
|
|
408
|
+
if (namedMetric && aliases.has(namedMetric)) {
|
|
409
|
+
const metricValue = readMetricValueFromRecord(node, aliases);
|
|
410
|
+
if (metricValue !== null) {
|
|
411
|
+
return { value: metricValue, path };
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const directValue = readMetricValueFromRecord(node, aliases);
|
|
415
|
+
if (directValue !== null && path !== "$") {
|
|
416
|
+
return { value: directValue, path };
|
|
417
|
+
}
|
|
418
|
+
for (const [key, value] of Object.entries(node)) {
|
|
419
|
+
if (aliases.has(normalizeMetricKey(key))) {
|
|
420
|
+
if (isRecord(value)) {
|
|
421
|
+
const nested = readMetricValueFromRecord(value);
|
|
422
|
+
if (nested !== null) {
|
|
423
|
+
return { value: nested, path: `${path}.${key}` };
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const direct = parseNumericValue(value);
|
|
427
|
+
if (direct !== null) {
|
|
428
|
+
return { value: direct, path: `${path}.${key}` };
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
for (const [key, value] of Object.entries(node)) {
|
|
433
|
+
const found = search(value, `${path}.${key}`);
|
|
434
|
+
if (found) {
|
|
435
|
+
return found;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return null;
|
|
439
|
+
};
|
|
440
|
+
const extracted = search(payload, "$");
|
|
441
|
+
return extracted ? extracted : { value: null };
|
|
442
|
+
}
|
|
443
|
+
export function extractActiveSubscriptionsValue(payload) {
|
|
444
|
+
return extractMetricValue(payload, "active_subs");
|
|
445
|
+
}
|
|
446
|
+
export function extractTimeseries(payload, metricKey) {
|
|
447
|
+
const aliases = metricKey ? expandMetricAliases(metricKey) : undefined;
|
|
448
|
+
const visited = new WeakSet();
|
|
449
|
+
const fromLabelsData = (node, path) => {
|
|
450
|
+
const labels = Array.isArray(node.labels) ? node.labels : null;
|
|
451
|
+
if (!labels) {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
const dataCandidate = (Array.isArray(node.data) ? node.data : null) ??
|
|
455
|
+
(Array.isArray(node.values) ? node.values : null) ??
|
|
456
|
+
(Array.isArray(node.points) ? node.points : null);
|
|
457
|
+
if (!dataCandidate || dataCandidate.length !== labels.length) {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
const points = [];
|
|
461
|
+
for (let index = 0; index < labels.length; index += 1) {
|
|
462
|
+
const date = parseDateValue(labels[index]);
|
|
463
|
+
const value = parseNumericValue(dataCandidate[index]);
|
|
464
|
+
if (!date || value === null) {
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
points.push({ date, value });
|
|
468
|
+
}
|
|
469
|
+
if (points.length === 0) {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
return { points, path };
|
|
473
|
+
};
|
|
474
|
+
const parsePointRecord = (record) => {
|
|
475
|
+
const date = extractDateFromRecord(record);
|
|
476
|
+
if (!date) {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
let value = null;
|
|
480
|
+
if (aliases) {
|
|
481
|
+
value = readMetricValueFromRecord(record, aliases);
|
|
482
|
+
}
|
|
483
|
+
if (value === null) {
|
|
484
|
+
value = readMetricValueFromRecord(record);
|
|
485
|
+
}
|
|
486
|
+
if (value === null) {
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
return { date, value };
|
|
490
|
+
};
|
|
491
|
+
const search = (node, path) => {
|
|
492
|
+
if (Array.isArray(node)) {
|
|
493
|
+
const points = node
|
|
494
|
+
.map((item) => (isRecord(item) ? parsePointRecord(item) : null))
|
|
495
|
+
.filter((point) => Boolean(point));
|
|
496
|
+
if (points.length >= 2) {
|
|
497
|
+
return { points, path };
|
|
498
|
+
}
|
|
499
|
+
for (let index = 0; index < node.length; index += 1) {
|
|
500
|
+
const found = search(node[index], `${path}[${index}]`);
|
|
501
|
+
if (found) {
|
|
502
|
+
return found;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
if (!isRecord(node)) {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
if (visited.has(node)) {
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
visited.add(node);
|
|
514
|
+
const labelsData = fromLabelsData(node, path);
|
|
515
|
+
if (labelsData) {
|
|
516
|
+
return labelsData;
|
|
517
|
+
}
|
|
518
|
+
for (const [key, value] of Object.entries(node)) {
|
|
519
|
+
const found = search(value, `${path}.${key}`);
|
|
520
|
+
if (found) {
|
|
521
|
+
return found;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return null;
|
|
525
|
+
};
|
|
526
|
+
const extracted = search(payload, "$");
|
|
527
|
+
if (!extracted) {
|
|
528
|
+
return { points: [] };
|
|
529
|
+
}
|
|
530
|
+
const deduped = extracted.points
|
|
531
|
+
.slice()
|
|
532
|
+
.sort((a, b) => a.date.localeCompare(b.date))
|
|
533
|
+
.filter((point, index, list) => index === 0 || point.date !== list[index - 1]?.date);
|
|
534
|
+
return { points: deduped, path: extracted.path };
|
|
535
|
+
}
|
|
536
|
+
export function extractBreakdown(payload, metricKey) {
|
|
537
|
+
const aliases = metricKey ? expandMetricAliases(metricKey) : undefined;
|
|
538
|
+
const visited = new WeakSet();
|
|
539
|
+
const fromLabelsData = (node, path) => {
|
|
540
|
+
const labels = Array.isArray(node.labels) ? node.labels : null;
|
|
541
|
+
if (!labels) {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
const dataCandidate = (Array.isArray(node.data) ? node.data : null) ??
|
|
545
|
+
(Array.isArray(node.values) ? node.values : null) ??
|
|
546
|
+
(Array.isArray(node.points) ? node.points : null);
|
|
547
|
+
if (!dataCandidate || dataCandidate.length !== labels.length) {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
const rows = [];
|
|
551
|
+
for (let index = 0; index < labels.length; index += 1) {
|
|
552
|
+
const key = labels[index];
|
|
553
|
+
const numeric = parseNumericValue(dataCandidate[index]);
|
|
554
|
+
if (typeof key !== "string" || key.trim().length === 0 || numeric === null) {
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
rows.push({ key, value: numeric });
|
|
558
|
+
}
|
|
559
|
+
if (rows.length === 0) {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
return { rows, path };
|
|
563
|
+
};
|
|
564
|
+
const parseRow = (record) => {
|
|
565
|
+
const key = extractBreakdownKeyFromRecord(record);
|
|
566
|
+
if (!key) {
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
let value = null;
|
|
570
|
+
if (aliases) {
|
|
571
|
+
value = readMetricValueFromRecord(record, aliases);
|
|
572
|
+
}
|
|
573
|
+
if (value === null) {
|
|
574
|
+
value = readMetricValueFromRecord(record);
|
|
575
|
+
}
|
|
576
|
+
if (value === null) {
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
return { key, value };
|
|
580
|
+
};
|
|
581
|
+
const search = (node, path) => {
|
|
582
|
+
if (Array.isArray(node)) {
|
|
583
|
+
const rows = node
|
|
584
|
+
.map((item) => (isRecord(item) ? parseRow(item) : null))
|
|
585
|
+
.filter((row) => Boolean(row));
|
|
586
|
+
if (rows.length > 0) {
|
|
587
|
+
return { rows, path };
|
|
588
|
+
}
|
|
589
|
+
for (let index = 0; index < node.length; index += 1) {
|
|
590
|
+
const found = search(node[index], `${path}[${index}]`);
|
|
591
|
+
if (found) {
|
|
592
|
+
return found;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
if (!isRecord(node)) {
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
if (visited.has(node)) {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
visited.add(node);
|
|
604
|
+
const labelsData = fromLabelsData(node, path);
|
|
605
|
+
if (labelsData) {
|
|
606
|
+
return labelsData;
|
|
607
|
+
}
|
|
608
|
+
for (const [key, value] of Object.entries(node)) {
|
|
609
|
+
const found = search(value, `${path}.${key}`);
|
|
610
|
+
if (found) {
|
|
611
|
+
return found;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return null;
|
|
615
|
+
};
|
|
616
|
+
const extracted = search(payload, "$");
|
|
617
|
+
if (!extracted) {
|
|
618
|
+
return { rows: [] };
|
|
619
|
+
}
|
|
620
|
+
return {
|
|
621
|
+
rows: extracted.rows.sort((a, b) => b.value - a.value),
|
|
622
|
+
path: extracted.path,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
export function extractMetricCatalog(payload) {
|
|
626
|
+
const visited = new WeakSet();
|
|
627
|
+
const collected = new Map();
|
|
628
|
+
const maybeAdd = (rawKey, label) => {
|
|
629
|
+
const raw = typeof rawKey === "string" && rawKey.trim().length > 0 ? rawKey.trim() : undefined;
|
|
630
|
+
const rawLabel = typeof label === "string" && label.trim().length > 0 ? label.trim() : undefined;
|
|
631
|
+
const metricKey = normalizeMetricKey(raw ?? rawLabel ?? "");
|
|
632
|
+
if (!metricKey || !isLikelyMetricIdentifier(metricKey)) {
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (!collected.has(metricKey)) {
|
|
636
|
+
collected.set(metricKey, {
|
|
637
|
+
metric_key: metricKey,
|
|
638
|
+
label: rawLabel ?? raw ?? metricKey,
|
|
639
|
+
raw_key: raw,
|
|
640
|
+
});
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
const existing = collected.get(metricKey);
|
|
644
|
+
if (!existing) {
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if ((!existing.raw_key || existing.raw_key.length === 0) && raw) {
|
|
648
|
+
existing.raw_key = raw;
|
|
649
|
+
}
|
|
650
|
+
if (existing.label === metricKey && rawLabel) {
|
|
651
|
+
existing.label = rawLabel;
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
const walk = (node) => {
|
|
655
|
+
if (Array.isArray(node)) {
|
|
656
|
+
for (const item of node) {
|
|
657
|
+
walk(item);
|
|
658
|
+
}
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (!isRecord(node)) {
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
if (visited.has(node)) {
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
visited.add(node);
|
|
668
|
+
maybeAdd(node.key ?? node.id ?? node.metric ?? node.code ?? node.slug, node.name ?? node.label ?? node.title);
|
|
669
|
+
for (const [key, value] of Object.entries(node)) {
|
|
670
|
+
if (["key", "id", "metric", "code", "slug"].includes(key)) {
|
|
671
|
+
maybeAdd(value, node.name ?? node.label ?? node.title);
|
|
672
|
+
}
|
|
673
|
+
walk(value);
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
walk(payload);
|
|
677
|
+
if (collected.size === 0) {
|
|
678
|
+
const fallback = [
|
|
679
|
+
"active_subs",
|
|
680
|
+
"active_trials",
|
|
681
|
+
"revenue_gross",
|
|
682
|
+
"refunds",
|
|
683
|
+
"trials_started",
|
|
684
|
+
"trials_converted",
|
|
685
|
+
"new_subscriptions",
|
|
686
|
+
"renewals",
|
|
687
|
+
"cancellations",
|
|
688
|
+
"subscribers_retention",
|
|
689
|
+
"cumulative_ltv",
|
|
690
|
+
];
|
|
691
|
+
for (const metric of fallback) {
|
|
692
|
+
collected.set(metric, {
|
|
693
|
+
metric_key: metric,
|
|
694
|
+
label: metric,
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
return Array.from(collected.values()).sort((a, b) => a.metric_key.localeCompare(b.metric_key));
|
|
699
|
+
}
|
|
700
|
+
function parseDashboardEventRecord(record) {
|
|
701
|
+
const eventTypeRaw = readRecordString(record, EVENT_TYPE_KEYS);
|
|
702
|
+
const eventType = normalizeEventType(eventTypeRaw);
|
|
703
|
+
const userId = readRecordString(record, EVENT_USER_ID_KEYS);
|
|
704
|
+
let occurredAt = null;
|
|
705
|
+
for (const key of ["occurred_at", "created_at", "timestamp", "time", "purchased_at"]) {
|
|
706
|
+
const parsed = parseDateValue(record[key]);
|
|
707
|
+
if (parsed) {
|
|
708
|
+
occurredAt = parsed;
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (!occurredAt) {
|
|
713
|
+
occurredAt = extractDateFromRecord(record);
|
|
714
|
+
}
|
|
715
|
+
const eventId = readRecordString(record, EVENT_ID_KEYS);
|
|
716
|
+
const productId = readRecordString(record, EVENT_PRODUCT_ID_KEYS);
|
|
717
|
+
const originalTransactionId = readRecordString(record, EVENT_ORIGINAL_TRANSACTION_ID_KEYS);
|
|
718
|
+
const subscriptionStatus = readRecordString(record, EVENT_SUBSCRIPTION_STATUS_KEYS);
|
|
719
|
+
const country = readRecordString(record, EVENT_COUNTRY_KEYS);
|
|
720
|
+
const platform = normalizePlatform(readRecordString(record, EVENT_PLATFORM_KEYS));
|
|
721
|
+
const price = (() => {
|
|
722
|
+
for (const key of EVENT_PRICE_KEYS) {
|
|
723
|
+
const parsed = parseNumericValue(record[key]);
|
|
724
|
+
if (parsed !== null) {
|
|
725
|
+
return parsed;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return undefined;
|
|
729
|
+
})();
|
|
730
|
+
const currency = normalizeCurrency(readRecordString(record, EVENT_CURRENCY_KEYS));
|
|
731
|
+
let score = 0;
|
|
732
|
+
if (eventType) {
|
|
733
|
+
score += 3;
|
|
734
|
+
}
|
|
735
|
+
if (userId) {
|
|
736
|
+
score += 3;
|
|
737
|
+
}
|
|
738
|
+
if (occurredAt) {
|
|
739
|
+
score += 3;
|
|
740
|
+
}
|
|
741
|
+
if (productId || originalTransactionId || country || platform || price !== undefined) {
|
|
742
|
+
score += 1;
|
|
743
|
+
}
|
|
744
|
+
if (score < 3) {
|
|
745
|
+
return { score };
|
|
746
|
+
}
|
|
747
|
+
// Avoid false positives on generic date/value arrays by requiring core event identity.
|
|
748
|
+
if (!eventType && !userId) {
|
|
749
|
+
return { score };
|
|
750
|
+
}
|
|
751
|
+
// Event rows should have a stable locator: time or explicit event id.
|
|
752
|
+
if (!occurredAt && !eventId) {
|
|
753
|
+
return { score };
|
|
754
|
+
}
|
|
755
|
+
return {
|
|
756
|
+
score,
|
|
757
|
+
event: {
|
|
758
|
+
event_id: eventId,
|
|
759
|
+
event_type: eventType ?? "unknown",
|
|
760
|
+
occurred_at: occurredAt ?? undefined,
|
|
761
|
+
user_id: userId,
|
|
762
|
+
product_id: productId,
|
|
763
|
+
original_transaction_id: originalTransactionId,
|
|
764
|
+
subscription_status: subscriptionStatus,
|
|
765
|
+
country,
|
|
766
|
+
platform,
|
|
767
|
+
price,
|
|
768
|
+
currency,
|
|
769
|
+
raw: record,
|
|
770
|
+
},
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
function extractDashboardEventsPagination(payload) {
|
|
774
|
+
const visited = new WeakSet();
|
|
775
|
+
let nextCursor;
|
|
776
|
+
let hasMore;
|
|
777
|
+
const readCursor = (record, keys) => {
|
|
778
|
+
for (const key of keys) {
|
|
779
|
+
const value = readStringValue(record[key]);
|
|
780
|
+
if (value) {
|
|
781
|
+
return value;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return undefined;
|
|
785
|
+
};
|
|
786
|
+
const walk = (node) => {
|
|
787
|
+
if (Array.isArray(node)) {
|
|
788
|
+
for (const item of node) {
|
|
789
|
+
walk(item);
|
|
790
|
+
}
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (!isRecord(node)) {
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
if (visited.has(node)) {
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
visited.add(node);
|
|
800
|
+
if (hasMore === undefined) {
|
|
801
|
+
for (const key of ["has_more", "hasMore", "has_next", "hasNext", "more"]) {
|
|
802
|
+
const value = readBooleanValue(node[key]);
|
|
803
|
+
if (value !== undefined) {
|
|
804
|
+
hasMore = value;
|
|
805
|
+
break;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
if (!nextCursor) {
|
|
810
|
+
nextCursor = readCursor(node, [
|
|
811
|
+
"next_cursor",
|
|
812
|
+
"nextCursor",
|
|
813
|
+
"next_page_cursor",
|
|
814
|
+
"nextPageCursor",
|
|
815
|
+
"next_page_token",
|
|
816
|
+
"nextPageToken",
|
|
817
|
+
"continuation_token",
|
|
818
|
+
"continuationToken",
|
|
819
|
+
"after",
|
|
820
|
+
"next",
|
|
821
|
+
]);
|
|
822
|
+
}
|
|
823
|
+
for (const value of Object.values(node)) {
|
|
824
|
+
walk(value);
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
walk(payload);
|
|
828
|
+
return {
|
|
829
|
+
nextCursor,
|
|
830
|
+
hasMore: hasMore ?? (nextCursor ? true : undefined),
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
export function extractDashboardEvents(payload) {
|
|
834
|
+
const visited = new WeakSet();
|
|
835
|
+
let best;
|
|
836
|
+
const maybeTake = (candidate) => {
|
|
837
|
+
if (candidate.events.length === 0) {
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
if (!best) {
|
|
841
|
+
best = candidate;
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
if (candidate.score > best.score) {
|
|
845
|
+
best = candidate;
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
if (candidate.score === best.score && candidate.events.length > best.events.length) {
|
|
849
|
+
best = candidate;
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
const walk = (node, path) => {
|
|
853
|
+
if (Array.isArray(node)) {
|
|
854
|
+
const parsed = node
|
|
855
|
+
.map((item) => (isRecord(item) ? parseDashboardEventRecord(item) : { score: 0 }))
|
|
856
|
+
.filter((result) => Boolean(result.event));
|
|
857
|
+
if (parsed.length > 0) {
|
|
858
|
+
maybeTake({
|
|
859
|
+
events: parsed.map((entry) => entry.event),
|
|
860
|
+
path,
|
|
861
|
+
score: parsed.reduce((sum, entry) => sum + entry.score, 0),
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
for (let index = 0; index < node.length; index += 1) {
|
|
865
|
+
walk(node[index], `${path}[${index}]`);
|
|
866
|
+
}
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
if (!isRecord(node)) {
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
if (visited.has(node)) {
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
visited.add(node);
|
|
876
|
+
for (const [key, value] of Object.entries(node)) {
|
|
877
|
+
walk(value, `${path}.${key}`);
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
walk(payload, "$");
|
|
881
|
+
const pagination = extractDashboardEventsPagination(payload);
|
|
882
|
+
return {
|
|
883
|
+
events: best?.events ?? [],
|
|
884
|
+
path: best?.path,
|
|
885
|
+
nextCursor: pagination.nextCursor,
|
|
886
|
+
hasMore: pagination.hasMore,
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
function getAnalyticsStatusFromError(error) {
|
|
890
|
+
if (!isApphudMcpError(error) || !error.details) {
|
|
891
|
+
return undefined;
|
|
892
|
+
}
|
|
893
|
+
const status = error.details.status;
|
|
894
|
+
return typeof status === "number" ? status : undefined;
|
|
895
|
+
}
|
|
896
|
+
function shouldTryNextAnalyticsCandidate(error) {
|
|
897
|
+
if (!isApphudMcpError(error)) {
|
|
898
|
+
return false;
|
|
899
|
+
}
|
|
900
|
+
const status = getAnalyticsStatusFromError(error);
|
|
901
|
+
return error.code === "UPSTREAM_ERROR" && (status === 404 || status === 405);
|
|
902
|
+
}
|
|
903
|
+
export class ApphudClient {
|
|
904
|
+
config;
|
|
905
|
+
secretStore;
|
|
906
|
+
analyticsSessionCookie = null;
|
|
907
|
+
analyticsLoginInFlight = null;
|
|
908
|
+
constructor(config, secretStore) {
|
|
909
|
+
this.config = config;
|
|
910
|
+
this.secretStore = secretStore;
|
|
911
|
+
}
|
|
912
|
+
async fetchCustomer(app, userId) {
|
|
913
|
+
const apiKey = await this.secretStore.getSecret(app.secretsRef);
|
|
914
|
+
if (!apiKey) {
|
|
915
|
+
throw new ApphudMcpError("FORBIDDEN", `Missing Apphud API key secret for ref ${app.secretsRef}`, {
|
|
916
|
+
statusCode: 403,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
const baseUrl = this.config.apphudCustomersApiBaseUrl.replace(/\/$/, "");
|
|
920
|
+
const url = `${baseUrl}/${encodeURIComponent(userId)}`;
|
|
921
|
+
const headers = new Headers();
|
|
922
|
+
headers.set(this.config.apphudCustomersApiAuthHeader, `${this.config.apphudCustomersApiAuthPrefix}${apiKey}`);
|
|
923
|
+
headers.set("Accept", "application/json");
|
|
924
|
+
let response;
|
|
925
|
+
try {
|
|
926
|
+
response = await fetch(url, {
|
|
927
|
+
method: "GET",
|
|
928
|
+
headers,
|
|
929
|
+
signal: AbortSignal.timeout(10_000),
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
catch (error) {
|
|
933
|
+
throw new ApphudMcpError("UPSTREAM_TIMEOUT", "Failed to fetch Apphud Customers API", {
|
|
934
|
+
statusCode: 504,
|
|
935
|
+
details: { reason: error instanceof Error ? error.message : "unknown" },
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
if (response.status === 402 || response.status === 403) {
|
|
939
|
+
throw new ApphudMcpError("APPHUD_CUSTOMERS_API_UNAVAILABLE", "Customers API is not available for this Apphud plan", {
|
|
940
|
+
statusCode: 403,
|
|
941
|
+
actionHint: "Enable Customers API access in Apphud plan and verify API key permissions",
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
if (!response.ok) {
|
|
945
|
+
const bodyText = await response.text();
|
|
946
|
+
throw new ApphudMcpError("UPSTREAM_ERROR", `Apphud Customers API request failed with status ${response.status}`, {
|
|
947
|
+
statusCode: 502,
|
|
948
|
+
details: { status: response.status, body: bodyText.slice(0, 500) },
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
const payloadUnknown = (await response.json());
|
|
952
|
+
const payload = asRecord(payloadUnknown);
|
|
953
|
+
const normalized = normalizeCustomer(userId, payload);
|
|
954
|
+
return {
|
|
955
|
+
rawPayload: payload,
|
|
956
|
+
normalized,
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
async listAnalyticsMetrics(app, options) {
|
|
960
|
+
const apphudAppId = options?.apphudAppId ?? app.appId;
|
|
961
|
+
const attempts = [
|
|
962
|
+
{
|
|
963
|
+
path: "/api/v1/chart/list",
|
|
964
|
+
method: "GET",
|
|
965
|
+
query: { app: apphudAppId },
|
|
966
|
+
},
|
|
967
|
+
{
|
|
968
|
+
path: "/api/v1/dash/options",
|
|
969
|
+
method: "GET",
|
|
970
|
+
query: { app: apphudAppId },
|
|
971
|
+
},
|
|
972
|
+
];
|
|
973
|
+
let lastError;
|
|
974
|
+
for (const attempt of attempts) {
|
|
975
|
+
try {
|
|
976
|
+
const response = await this.requestAnalytics({
|
|
977
|
+
app,
|
|
978
|
+
path: attempt.path,
|
|
979
|
+
method: attempt.method,
|
|
980
|
+
query: attempt.query,
|
|
981
|
+
body: attempt.body,
|
|
982
|
+
});
|
|
983
|
+
const metrics = extractMetricCatalog(response.payload);
|
|
984
|
+
if (metrics.length > 0) {
|
|
985
|
+
return {
|
|
986
|
+
apphudAppId,
|
|
987
|
+
metrics,
|
|
988
|
+
sourcePath: attempt.path,
|
|
989
|
+
rawPayload: response.payload,
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
catch (error) {
|
|
994
|
+
if (!shouldTryNextAnalyticsCandidate(error)) {
|
|
995
|
+
throw error;
|
|
996
|
+
}
|
|
997
|
+
lastError = error;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
if (lastError) {
|
|
1001
|
+
throw lastError;
|
|
1002
|
+
}
|
|
1003
|
+
return {
|
|
1004
|
+
apphudAppId,
|
|
1005
|
+
metrics: extractMetricCatalog({}),
|
|
1006
|
+
sourcePath: "static_fallback",
|
|
1007
|
+
rawPayload: {},
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
async listDashboardApps(app) {
|
|
1011
|
+
const authApp = app ?? this.createSyntheticAuthApp();
|
|
1012
|
+
const attempts = [
|
|
1013
|
+
{ path: "/apps", method: "GET" },
|
|
1014
|
+
{ path: "/apps/list", method: "GET" },
|
|
1015
|
+
{ path: "/api/v1/apps", method: "GET" },
|
|
1016
|
+
{ path: "/api/v1/apps/list", method: "GET" },
|
|
1017
|
+
{ path: "/dash/apps", method: "GET" },
|
|
1018
|
+
];
|
|
1019
|
+
let lastError;
|
|
1020
|
+
for (const attempt of attempts) {
|
|
1021
|
+
try {
|
|
1022
|
+
const response = await this.requestAnalytics({
|
|
1023
|
+
app: authApp,
|
|
1024
|
+
path: attempt.path,
|
|
1025
|
+
method: attempt.method,
|
|
1026
|
+
});
|
|
1027
|
+
const apps = extractDashboardApps(response.payload);
|
|
1028
|
+
if (apps.length > 0) {
|
|
1029
|
+
return {
|
|
1030
|
+
apps,
|
|
1031
|
+
sourcePath: attempt.path,
|
|
1032
|
+
rawPayload: response.payload,
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
catch (error) {
|
|
1037
|
+
if (!shouldTryNextAnalyticsCandidate(error)) {
|
|
1038
|
+
throw error;
|
|
1039
|
+
}
|
|
1040
|
+
lastError = error;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
if (lastError) {
|
|
1044
|
+
throw lastError;
|
|
1045
|
+
}
|
|
1046
|
+
return {
|
|
1047
|
+
apps: [],
|
|
1048
|
+
sourcePath: "no_apps_detected",
|
|
1049
|
+
rawPayload: {},
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
async fetchDashboardEvents(app, options) {
|
|
1053
|
+
const apphudAppId = options.apphudAppId ?? app.appId;
|
|
1054
|
+
const limit = Math.min(Math.max(options.limit ?? 100, 1), 500);
|
|
1055
|
+
const normalizedEventType = normalizeEventType(options.eventType);
|
|
1056
|
+
const filters = this.normalizeFilters({
|
|
1057
|
+
event_type: normalizedEventType,
|
|
1058
|
+
user_id: options.userId,
|
|
1059
|
+
product_id: options.productId,
|
|
1060
|
+
country: options.country,
|
|
1061
|
+
platform: options.platform,
|
|
1062
|
+
});
|
|
1063
|
+
const bodyBase = {
|
|
1064
|
+
app: apphudAppId,
|
|
1065
|
+
from: options.from,
|
|
1066
|
+
to: options.to,
|
|
1067
|
+
time_range: {
|
|
1068
|
+
from: options.from,
|
|
1069
|
+
to: options.to,
|
|
1070
|
+
},
|
|
1071
|
+
limit,
|
|
1072
|
+
filters,
|
|
1073
|
+
use_and: true,
|
|
1074
|
+
};
|
|
1075
|
+
if (options.cursor) {
|
|
1076
|
+
bodyBase.cursor = options.cursor;
|
|
1077
|
+
bodyBase.page_cursor = options.cursor;
|
|
1078
|
+
bodyBase.after = options.cursor;
|
|
1079
|
+
}
|
|
1080
|
+
if (normalizedEventType) {
|
|
1081
|
+
bodyBase.event_type = normalizedEventType;
|
|
1082
|
+
bodyBase.event = normalizedEventType;
|
|
1083
|
+
bodyBase.type = normalizedEventType;
|
|
1084
|
+
}
|
|
1085
|
+
if (options.userId) {
|
|
1086
|
+
bodyBase.user_id = options.userId;
|
|
1087
|
+
bodyBase.customer_user_id = options.userId;
|
|
1088
|
+
}
|
|
1089
|
+
if (options.productId) {
|
|
1090
|
+
bodyBase.product_id = options.productId;
|
|
1091
|
+
}
|
|
1092
|
+
if (options.country) {
|
|
1093
|
+
bodyBase.country = options.country;
|
|
1094
|
+
}
|
|
1095
|
+
if (options.platform) {
|
|
1096
|
+
bodyBase.platform = options.platform;
|
|
1097
|
+
}
|
|
1098
|
+
const queryBase = {
|
|
1099
|
+
app: apphudAppId,
|
|
1100
|
+
from: options.from,
|
|
1101
|
+
to: options.to,
|
|
1102
|
+
limit: String(limit),
|
|
1103
|
+
};
|
|
1104
|
+
if (options.cursor) {
|
|
1105
|
+
queryBase.cursor = options.cursor;
|
|
1106
|
+
queryBase.after = options.cursor;
|
|
1107
|
+
queryBase.page_cursor = options.cursor;
|
|
1108
|
+
}
|
|
1109
|
+
if (normalizedEventType) {
|
|
1110
|
+
queryBase.event_type = normalizedEventType;
|
|
1111
|
+
queryBase.event = normalizedEventType;
|
|
1112
|
+
queryBase.type = normalizedEventType;
|
|
1113
|
+
}
|
|
1114
|
+
if (options.userId) {
|
|
1115
|
+
queryBase.user_id = options.userId;
|
|
1116
|
+
queryBase.customer_user_id = options.userId;
|
|
1117
|
+
}
|
|
1118
|
+
if (options.productId) {
|
|
1119
|
+
queryBase.product_id = options.productId;
|
|
1120
|
+
}
|
|
1121
|
+
if (options.country) {
|
|
1122
|
+
queryBase.country = options.country;
|
|
1123
|
+
}
|
|
1124
|
+
if (options.platform) {
|
|
1125
|
+
queryBase.platform = options.platform;
|
|
1126
|
+
}
|
|
1127
|
+
const attempts = [
|
|
1128
|
+
{ path: "/api/v1/events/list", method: "POST", body: bodyBase },
|
|
1129
|
+
{ path: "/api/v1/events", method: "POST", body: bodyBase },
|
|
1130
|
+
{ path: "/api/v1/subscribers/events", method: "POST", body: bodyBase },
|
|
1131
|
+
{ path: "/api/v1/events/list", method: "GET", query: queryBase },
|
|
1132
|
+
{ path: "/api/v1/events", method: "GET", query: queryBase },
|
|
1133
|
+
{ path: "/events/list", method: "GET", query: queryBase },
|
|
1134
|
+
{ path: "/events", method: "GET", query: queryBase },
|
|
1135
|
+
];
|
|
1136
|
+
let firstSuccess;
|
|
1137
|
+
let lastError;
|
|
1138
|
+
for (const attempt of attempts) {
|
|
1139
|
+
try {
|
|
1140
|
+
const response = await this.requestAnalytics({
|
|
1141
|
+
app,
|
|
1142
|
+
path: attempt.path,
|
|
1143
|
+
method: attempt.method,
|
|
1144
|
+
query: attempt.query,
|
|
1145
|
+
body: attempt.body,
|
|
1146
|
+
});
|
|
1147
|
+
const extracted = extractDashboardEvents(response.payload);
|
|
1148
|
+
const current = {
|
|
1149
|
+
apphudAppId,
|
|
1150
|
+
events: extracted.events,
|
|
1151
|
+
sourcePath: attempt.path,
|
|
1152
|
+
extractionPath: extracted.path,
|
|
1153
|
+
nextCursor: extracted.nextCursor,
|
|
1154
|
+
hasMore: extracted.hasMore,
|
|
1155
|
+
rawPayload: response.payload,
|
|
1156
|
+
};
|
|
1157
|
+
if (!firstSuccess) {
|
|
1158
|
+
firstSuccess = current;
|
|
1159
|
+
}
|
|
1160
|
+
if (current.events.length > 0 || current.nextCursor || current.hasMore !== undefined) {
|
|
1161
|
+
return current;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
catch (error) {
|
|
1165
|
+
if (!shouldTryNextAnalyticsCandidate(error)) {
|
|
1166
|
+
throw error;
|
|
1167
|
+
}
|
|
1168
|
+
lastError = error;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
if (firstSuccess) {
|
|
1172
|
+
return firstSuccess;
|
|
1173
|
+
}
|
|
1174
|
+
if (lastError) {
|
|
1175
|
+
throw lastError;
|
|
1176
|
+
}
|
|
1177
|
+
return {
|
|
1178
|
+
apphudAppId,
|
|
1179
|
+
events: [],
|
|
1180
|
+
sourcePath: "no_events_detected",
|
|
1181
|
+
rawPayload: {},
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
async fetchDashNow(app, options) {
|
|
1185
|
+
const apphudAppId = options?.apphudAppId ?? app.appId;
|
|
1186
|
+
const body = {
|
|
1187
|
+
use_and: true,
|
|
1188
|
+
app: apphudAppId,
|
|
1189
|
+
filters: this.normalizeFilters(options?.filters),
|
|
1190
|
+
};
|
|
1191
|
+
if (options?.platform) {
|
|
1192
|
+
body.platform = options.platform;
|
|
1193
|
+
}
|
|
1194
|
+
const response = await this.requestAnalytics({
|
|
1195
|
+
app,
|
|
1196
|
+
path: "/api/v1/dash/now",
|
|
1197
|
+
method: "POST",
|
|
1198
|
+
body,
|
|
1199
|
+
});
|
|
1200
|
+
return {
|
|
1201
|
+
apphudAppId,
|
|
1202
|
+
payload: response.payload,
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
async fetchDashRange(app, options) {
|
|
1206
|
+
const apphudAppId = options.apphudAppId ?? app.appId;
|
|
1207
|
+
const body = {
|
|
1208
|
+
use_and: true,
|
|
1209
|
+
app: apphudAppId,
|
|
1210
|
+
time_range: {
|
|
1211
|
+
from: options.from,
|
|
1212
|
+
to: options.to,
|
|
1213
|
+
},
|
|
1214
|
+
filters: this.normalizeFilters(options.filters),
|
|
1215
|
+
};
|
|
1216
|
+
if (options.platform) {
|
|
1217
|
+
body.platform = options.platform;
|
|
1218
|
+
}
|
|
1219
|
+
if (options.granularity) {
|
|
1220
|
+
body.granularity = options.granularity;
|
|
1221
|
+
}
|
|
1222
|
+
if (options.metricKey) {
|
|
1223
|
+
body.chart = options.metricKey;
|
|
1224
|
+
body.metric = options.metricKey;
|
|
1225
|
+
}
|
|
1226
|
+
if (options.dimension) {
|
|
1227
|
+
body.dimension = options.dimension;
|
|
1228
|
+
body.group_by = options.dimension;
|
|
1229
|
+
body.segment_by = options.dimension;
|
|
1230
|
+
}
|
|
1231
|
+
const response = await this.requestAnalytics({
|
|
1232
|
+
app,
|
|
1233
|
+
path: "/api/v1/dash/range",
|
|
1234
|
+
method: "POST",
|
|
1235
|
+
body,
|
|
1236
|
+
});
|
|
1237
|
+
return {
|
|
1238
|
+
apphudAppId,
|
|
1239
|
+
payload: response.payload,
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
async fetchChartQuery(app, options) {
|
|
1243
|
+
const apphudAppId = options.apphudAppId ?? app.appId;
|
|
1244
|
+
const body = {
|
|
1245
|
+
app: apphudAppId,
|
|
1246
|
+
chart: options.metricKey,
|
|
1247
|
+
metric: options.metricKey,
|
|
1248
|
+
use_and: true,
|
|
1249
|
+
filters: this.normalizeFilters(options.filters),
|
|
1250
|
+
};
|
|
1251
|
+
if (options.platform) {
|
|
1252
|
+
body.platform = options.platform;
|
|
1253
|
+
}
|
|
1254
|
+
if (options.granularity) {
|
|
1255
|
+
body.granularity = options.granularity;
|
|
1256
|
+
}
|
|
1257
|
+
if (options.dimension) {
|
|
1258
|
+
body.dimension = options.dimension;
|
|
1259
|
+
body.group_by = options.dimension;
|
|
1260
|
+
body.segment_by = options.dimension;
|
|
1261
|
+
}
|
|
1262
|
+
if (options.from && options.to) {
|
|
1263
|
+
body.from = options.from;
|
|
1264
|
+
body.to = options.to;
|
|
1265
|
+
body.time_range = {
|
|
1266
|
+
from: options.from,
|
|
1267
|
+
to: options.to,
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
const sourcePath = `/api/v1/chart/query/${options.shape}`;
|
|
1271
|
+
const response = await this.requestAnalytics({
|
|
1272
|
+
app,
|
|
1273
|
+
path: sourcePath,
|
|
1274
|
+
method: "POST",
|
|
1275
|
+
body,
|
|
1276
|
+
});
|
|
1277
|
+
return {
|
|
1278
|
+
apphudAppId,
|
|
1279
|
+
payload: response.payload,
|
|
1280
|
+
sourcePath,
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
async fetchActiveSubscriptions(app, options) {
|
|
1284
|
+
const dash = await this.fetchDashNow(app, {
|
|
1285
|
+
apphudAppId: options?.apphudAppId,
|
|
1286
|
+
platform: options?.platform,
|
|
1287
|
+
});
|
|
1288
|
+
const extracted = extractActiveSubscriptionsValue(dash.payload);
|
|
1289
|
+
if (extracted.value !== null) {
|
|
1290
|
+
return {
|
|
1291
|
+
apphudAppId: dash.apphudAppId,
|
|
1292
|
+
value: extracted.value,
|
|
1293
|
+
extractedFrom: extracted.path,
|
|
1294
|
+
rawPayload: dash.payload,
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
const chart = await this.fetchChartQuery(app, {
|
|
1298
|
+
shape: "line",
|
|
1299
|
+
metricKey: "active_subs",
|
|
1300
|
+
apphudAppId: dash.apphudAppId,
|
|
1301
|
+
platform: options?.platform,
|
|
1302
|
+
});
|
|
1303
|
+
const fromSeries = extractTimeseries(chart.payload, "active_subs");
|
|
1304
|
+
const lastPoint = fromSeries.points[fromSeries.points.length - 1];
|
|
1305
|
+
return {
|
|
1306
|
+
apphudAppId: dash.apphudAppId,
|
|
1307
|
+
value: lastPoint?.value ?? null,
|
|
1308
|
+
extractedFrom: fromSeries.path,
|
|
1309
|
+
rawPayload: chart.payload,
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
async fetchAnalyticsRaw(app, input) {
|
|
1313
|
+
const apphudAppId = input.apphudAppId ?? app.appId;
|
|
1314
|
+
const normalizedPath = this.normalizeAnalyticsPath(input.path);
|
|
1315
|
+
const query = normalizedPath.startsWith("/api/v1/")
|
|
1316
|
+
? {
|
|
1317
|
+
app: input.query?.app ?? apphudAppId,
|
|
1318
|
+
...input.query,
|
|
1319
|
+
}
|
|
1320
|
+
: input.query;
|
|
1321
|
+
const response = await this.requestAnalytics({
|
|
1322
|
+
app,
|
|
1323
|
+
path: normalizedPath,
|
|
1324
|
+
method: input.method ?? "POST",
|
|
1325
|
+
query,
|
|
1326
|
+
body: input.body,
|
|
1327
|
+
});
|
|
1328
|
+
return {
|
|
1329
|
+
apphudAppId,
|
|
1330
|
+
payload: response.payload,
|
|
1331
|
+
status: response.status,
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
async requestAnalytics(input) {
|
|
1335
|
+
const baseUrl = this.config.apphudAnalyticsApiBaseUrl.replace(/\/$/, "");
|
|
1336
|
+
const cleanPath = this.normalizeAnalyticsPath(input.path);
|
|
1337
|
+
const url = new URL(`${baseUrl}${cleanPath}`);
|
|
1338
|
+
if (input.query) {
|
|
1339
|
+
for (const [key, value] of Object.entries(input.query)) {
|
|
1340
|
+
if (typeof value === "string" && value.length > 0) {
|
|
1341
|
+
url.searchParams.set(key, value);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
const headers = new Headers();
|
|
1346
|
+
headers.set("Accept", "application/json");
|
|
1347
|
+
if (input.method !== "GET") {
|
|
1348
|
+
headers.set("Content-Type", "application/json");
|
|
1349
|
+
}
|
|
1350
|
+
const analyticsSecretRef = this.config.apphudAnalyticsAuthSecretRef ??
|
|
1351
|
+
(this.config.apphudAnalyticsAuthHeader.toLowerCase() === "cookie" ? undefined : input.app.secretsRef);
|
|
1352
|
+
const analyticsSecret = analyticsSecretRef ? await this.secretStore.getSecret(analyticsSecretRef) : null;
|
|
1353
|
+
if (analyticsSecret) {
|
|
1354
|
+
headers.set(this.config.apphudAnalyticsAuthHeader, `${this.config.apphudAnalyticsAuthPrefix}${analyticsSecret}`);
|
|
1355
|
+
}
|
|
1356
|
+
else {
|
|
1357
|
+
const sessionCookie = await this.ensureAnalyticsSessionCookie();
|
|
1358
|
+
if (sessionCookie) {
|
|
1359
|
+
headers.set("Cookie", sessionCookie);
|
|
1360
|
+
}
|
|
1361
|
+
else if (analyticsSecretRef) {
|
|
1362
|
+
throw new ApphudMcpError("FORBIDDEN", `Missing Apphud analytics auth secret for ref ${analyticsSecretRef}`, {
|
|
1363
|
+
statusCode: 403,
|
|
1364
|
+
actionHint: "Set APPHUD_ANALYTICS_AUTH_SECRET_REF or configure apphud.analytics_login_* refs and provide matching secrets in env",
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
let firstAttempt = await this.executeAnalyticsHttpRequest(url, input.method, headers, input.body, cleanPath);
|
|
1369
|
+
if ((firstAttempt.response.status === 401 || firstAttempt.response.status === 403) && this.isAnalyticsAutoLoginConfigured()) {
|
|
1370
|
+
const refreshedCookie = await this.ensureAnalyticsSessionCookie(true);
|
|
1371
|
+
if (refreshedCookie) {
|
|
1372
|
+
const retryHeaders = new Headers(headers);
|
|
1373
|
+
retryHeaders.delete(this.config.apphudAnalyticsAuthHeader);
|
|
1374
|
+
retryHeaders.set("Cookie", refreshedCookie);
|
|
1375
|
+
firstAttempt = await this.executeAnalyticsHttpRequest(url, input.method, retryHeaders, input.body, cleanPath);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
if (firstAttempt.response.status === 401 || firstAttempt.response.status === 403) {
|
|
1379
|
+
throw new ApphudMcpError("APPHUD_ANALYTICS_API_UNAVAILABLE", "Apphud Analytics endpoint is not available with current credentials", {
|
|
1380
|
+
statusCode: 403,
|
|
1381
|
+
actionHint: "Verify analytics auth value or configure apphud.analytics_login_* (email/password refs) for auto-login and cookie refresh",
|
|
1382
|
+
details: {
|
|
1383
|
+
status: firstAttempt.response.status,
|
|
1384
|
+
endpoint: cleanPath,
|
|
1385
|
+
body: firstAttempt.bodyText.slice(0, 500),
|
|
1386
|
+
},
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
if (!firstAttempt.response.ok) {
|
|
1390
|
+
throw new ApphudMcpError("UPSTREAM_ERROR", `Apphud Analytics API request failed with status ${firstAttempt.response.status}`, {
|
|
1391
|
+
statusCode: 502,
|
|
1392
|
+
details: {
|
|
1393
|
+
status: firstAttempt.response.status,
|
|
1394
|
+
endpoint: cleanPath,
|
|
1395
|
+
body: firstAttempt.bodyText.slice(0, 500),
|
|
1396
|
+
},
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
return {
|
|
1400
|
+
status: firstAttempt.response.status,
|
|
1401
|
+
payload: this.parseAnalyticsBody(firstAttempt.bodyText),
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
createSyntheticAuthApp() {
|
|
1405
|
+
const now = new Date().toISOString();
|
|
1406
|
+
return {
|
|
1407
|
+
appId: "dashboard",
|
|
1408
|
+
tenantId: "tenant_default",
|
|
1409
|
+
name: "dashboard",
|
|
1410
|
+
status: "active",
|
|
1411
|
+
secretsRef: "UNUSED",
|
|
1412
|
+
webhookSecretRef: undefined,
|
|
1413
|
+
createdAt: now,
|
|
1414
|
+
updatedAt: now,
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
normalizeAnalyticsPath(path) {
|
|
1418
|
+
const normalized = path.startsWith("/") ? path : `/${path}`;
|
|
1419
|
+
if (normalized.startsWith("/api/")) {
|
|
1420
|
+
return normalized;
|
|
1421
|
+
}
|
|
1422
|
+
if (normalized.startsWith("/dash/") || normalized.startsWith("/chart/")) {
|
|
1423
|
+
return `/api/v1${normalized}`;
|
|
1424
|
+
}
|
|
1425
|
+
return normalized;
|
|
1426
|
+
}
|
|
1427
|
+
async executeAnalyticsHttpRequest(url, method, headers, body, endpoint) {
|
|
1428
|
+
let response;
|
|
1429
|
+
try {
|
|
1430
|
+
response = await fetch(url, {
|
|
1431
|
+
method,
|
|
1432
|
+
headers,
|
|
1433
|
+
body: method === "GET" ? undefined : JSON.stringify(body ?? {}),
|
|
1434
|
+
signal: AbortSignal.timeout(10_000),
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
catch (error) {
|
|
1438
|
+
throw new ApphudMcpError("UPSTREAM_TIMEOUT", "Failed to fetch Apphud Analytics API", {
|
|
1439
|
+
statusCode: 504,
|
|
1440
|
+
details: {
|
|
1441
|
+
endpoint,
|
|
1442
|
+
reason: error instanceof Error ? error.message : "unknown",
|
|
1443
|
+
},
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
return {
|
|
1447
|
+
response,
|
|
1448
|
+
bodyText: await response.text(),
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
parseAnalyticsBody(bodyText) {
|
|
1452
|
+
if (bodyText.length === 0) {
|
|
1453
|
+
return {};
|
|
1454
|
+
}
|
|
1455
|
+
try {
|
|
1456
|
+
return JSON.parse(bodyText);
|
|
1457
|
+
}
|
|
1458
|
+
catch {
|
|
1459
|
+
return { raw: bodyText };
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
isAnalyticsAutoLoginConfigured() {
|
|
1463
|
+
return Boolean(this.config.apphudAnalyticsLoginEmailSecretRef && this.config.apphudAnalyticsLoginPasswordSecretRef);
|
|
1464
|
+
}
|
|
1465
|
+
async ensureAnalyticsSessionCookie(forceRefresh = false) {
|
|
1466
|
+
if (!forceRefresh && this.analyticsSessionCookie) {
|
|
1467
|
+
return this.analyticsSessionCookie;
|
|
1468
|
+
}
|
|
1469
|
+
if (!this.isAnalyticsAutoLoginConfigured()) {
|
|
1470
|
+
return null;
|
|
1471
|
+
}
|
|
1472
|
+
if (this.analyticsLoginInFlight) {
|
|
1473
|
+
return this.analyticsLoginInFlight;
|
|
1474
|
+
}
|
|
1475
|
+
this.analyticsLoginInFlight = this.refreshAnalyticsSessionCookie();
|
|
1476
|
+
try {
|
|
1477
|
+
const cookie = await this.analyticsLoginInFlight;
|
|
1478
|
+
if (cookie) {
|
|
1479
|
+
this.analyticsSessionCookie = cookie;
|
|
1480
|
+
}
|
|
1481
|
+
return cookie;
|
|
1482
|
+
}
|
|
1483
|
+
finally {
|
|
1484
|
+
this.analyticsLoginInFlight = null;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
async refreshAnalyticsSessionCookie() {
|
|
1488
|
+
const emailRef = this.config.apphudAnalyticsLoginEmailSecretRef;
|
|
1489
|
+
const passwordRef = this.config.apphudAnalyticsLoginPasswordSecretRef;
|
|
1490
|
+
if (!emailRef || !passwordRef) {
|
|
1491
|
+
return null;
|
|
1492
|
+
}
|
|
1493
|
+
const [email, password] = await Promise.all([
|
|
1494
|
+
this.secretStore.getSecret(emailRef),
|
|
1495
|
+
this.secretStore.getSecret(passwordRef),
|
|
1496
|
+
]);
|
|
1497
|
+
if (!email || !password) {
|
|
1498
|
+
throw new ApphudMcpError("FORBIDDEN", "Missing Apphud analytics auto-login credentials", {
|
|
1499
|
+
statusCode: 403,
|
|
1500
|
+
details: {
|
|
1501
|
+
email_ref: emailRef,
|
|
1502
|
+
password_ref: passwordRef,
|
|
1503
|
+
},
|
|
1504
|
+
actionHint: "Set both apphud.analytics_login_* refs in config and matching env vars with Apphud dashboard email/password",
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
const loginPath = this.config.apphudAnalyticsLoginPath.startsWith("/")
|
|
1508
|
+
? this.config.apphudAnalyticsLoginPath
|
|
1509
|
+
: `/${this.config.apphudAnalyticsLoginPath}`;
|
|
1510
|
+
const loginCandidates = this.buildAnalyticsLoginCandidates(loginPath);
|
|
1511
|
+
const headers = new Headers();
|
|
1512
|
+
headers.set("Accept", "application/json");
|
|
1513
|
+
headers.set("Content-Type", "application/json");
|
|
1514
|
+
let lastStatus;
|
|
1515
|
+
let lastBody = "";
|
|
1516
|
+
let lastEndpoint = loginPath;
|
|
1517
|
+
for (const candidate of loginCandidates) {
|
|
1518
|
+
const loginRequest = await this.executeAnalyticsHttpRequest(new URL(candidate.url), "POST", headers, { email, password }, candidate.endpointLabel);
|
|
1519
|
+
lastStatus = loginRequest.response.status;
|
|
1520
|
+
lastBody = loginRequest.bodyText;
|
|
1521
|
+
lastEndpoint = candidate.endpointLabel;
|
|
1522
|
+
if (!loginRequest.response.ok) {
|
|
1523
|
+
if (loginRequest.response.status === 404) {
|
|
1524
|
+
continue;
|
|
1525
|
+
}
|
|
1526
|
+
throw new ApphudMcpError("APPHUD_ANALYTICS_API_UNAVAILABLE", "Apphud analytics auto-login failed", {
|
|
1527
|
+
statusCode: 403,
|
|
1528
|
+
actionHint: "Verify Apphud dashboard email/password and ensure account can log in without extra interactive steps",
|
|
1529
|
+
details: {
|
|
1530
|
+
status: loginRequest.response.status,
|
|
1531
|
+
endpoint: candidate.endpointLabel,
|
|
1532
|
+
body: loginRequest.bodyText.slice(0, 500),
|
|
1533
|
+
attempted_login_urls: loginCandidates.map((item) => item.endpointLabel),
|
|
1534
|
+
},
|
|
1535
|
+
});
|
|
1536
|
+
}
|
|
1537
|
+
const cookieHeader = this.extractCookieHeader(loginRequest.response.headers);
|
|
1538
|
+
if (cookieHeader) {
|
|
1539
|
+
return cookieHeader;
|
|
1540
|
+
}
|
|
1541
|
+
throw new ApphudMcpError("APPHUD_ANALYTICS_API_UNAVAILABLE", "Apphud analytics auto-login returned no session cookie", {
|
|
1542
|
+
statusCode: 403,
|
|
1543
|
+
actionHint: "Re-run login flow in Apphud dashboard and verify analytics endpoint still uses cookie auth",
|
|
1544
|
+
details: {
|
|
1545
|
+
endpoint: candidate.endpointLabel,
|
|
1546
|
+
attempted_login_urls: loginCandidates.map((item) => item.endpointLabel),
|
|
1547
|
+
},
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
throw new ApphudMcpError("APPHUD_ANALYTICS_API_UNAVAILABLE", "Apphud analytics auto-login failed", {
|
|
1551
|
+
statusCode: 403,
|
|
1552
|
+
actionHint: "Set apphud.analytics_login_path or analytics base URL compatible with your Apphud account (check attempted_login_urls)",
|
|
1553
|
+
details: {
|
|
1554
|
+
status: lastStatus,
|
|
1555
|
+
endpoint: lastEndpoint,
|
|
1556
|
+
body: lastBody.slice(0, 500),
|
|
1557
|
+
attempted_login_urls: loginCandidates.map((item) => item.endpointLabel),
|
|
1558
|
+
},
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
buildAnalyticsLoginCandidates(loginPath) {
|
|
1562
|
+
const analyticsBaseUrl = this.config.apphudAnalyticsApiBaseUrl.replace(/\/$/, "");
|
|
1563
|
+
const candidates = [];
|
|
1564
|
+
const seen = new Set();
|
|
1565
|
+
const pushCandidate = (baseUrl, path) => {
|
|
1566
|
+
const normalizedBase = baseUrl.replace(/\/$/, "");
|
|
1567
|
+
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
1568
|
+
const endpointLabel = `${normalizedBase}${normalizedPath}`;
|
|
1569
|
+
if (seen.has(endpointLabel)) {
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
seen.add(endpointLabel);
|
|
1573
|
+
candidates.push({
|
|
1574
|
+
url: endpointLabel,
|
|
1575
|
+
endpointLabel,
|
|
1576
|
+
});
|
|
1577
|
+
};
|
|
1578
|
+
pushCandidate(analyticsBaseUrl, loginPath);
|
|
1579
|
+
const rootBase = analyticsBaseUrl.replace(/\/api\/v\d+$/i, "");
|
|
1580
|
+
if (rootBase !== analyticsBaseUrl) {
|
|
1581
|
+
pushCandidate(rootBase, loginPath);
|
|
1582
|
+
if (!loginPath.startsWith("/api/")) {
|
|
1583
|
+
pushCandidate(rootBase, `/api/v1${loginPath.startsWith("/") ? loginPath : `/${loginPath}`}`);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
return candidates;
|
|
1587
|
+
}
|
|
1588
|
+
extractCookieHeader(headers) {
|
|
1589
|
+
const getSetCookie = headers.getSetCookie;
|
|
1590
|
+
const setCookieValues = typeof getSetCookie === "function" ? getSetCookie.call(headers) : this.parseSetCookieHeader(headers.get("set-cookie"));
|
|
1591
|
+
if (!setCookieValues || setCookieValues.length === 0) {
|
|
1592
|
+
return null;
|
|
1593
|
+
}
|
|
1594
|
+
const pairs = setCookieValues
|
|
1595
|
+
.map((cookieValue) => cookieValue.split(";")[0]?.trim() ?? "")
|
|
1596
|
+
.filter((cookieValue) => cookieValue.length > 0);
|
|
1597
|
+
if (pairs.length === 0) {
|
|
1598
|
+
return null;
|
|
1599
|
+
}
|
|
1600
|
+
return pairs.join("; ");
|
|
1601
|
+
}
|
|
1602
|
+
parseSetCookieHeader(value) {
|
|
1603
|
+
if (!value) {
|
|
1604
|
+
return [];
|
|
1605
|
+
}
|
|
1606
|
+
return value
|
|
1607
|
+
.split(/,(?=\s*[A-Za-z0-9_!#$%&'*+.^`|~-]+=)/g)
|
|
1608
|
+
.map((entry) => entry.trim())
|
|
1609
|
+
.filter((entry) => entry.length > 0);
|
|
1610
|
+
}
|
|
1611
|
+
normalizeFilters(filters) {
|
|
1612
|
+
if (!filters) {
|
|
1613
|
+
return [];
|
|
1614
|
+
}
|
|
1615
|
+
const normalized = [];
|
|
1616
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
1617
|
+
if (value === undefined || value === null) {
|
|
1618
|
+
continue;
|
|
1619
|
+
}
|
|
1620
|
+
normalized.push({
|
|
1621
|
+
id: key,
|
|
1622
|
+
values: [
|
|
1623
|
+
{
|
|
1624
|
+
condition: "equals",
|
|
1625
|
+
value: String(value),
|
|
1626
|
+
},
|
|
1627
|
+
],
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
return normalized;
|
|
1631
|
+
}
|
|
1632
|
+
}
|