@contractspec/integration.providers-impls 2.10.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +7 -1
  2. package/dist/impls/async-event-queue.d.ts +8 -0
  3. package/dist/impls/async-event-queue.js +47 -0
  4. package/dist/impls/health/base-health-provider.d.ts +64 -13
  5. package/dist/impls/health/base-health-provider.js +506 -156
  6. package/dist/impls/health/hybrid-health-providers.d.ts +34 -0
  7. package/dist/impls/health/hybrid-health-providers.js +1088 -0
  8. package/dist/impls/health/official-health-providers.d.ts +78 -0
  9. package/dist/impls/health/official-health-providers.js +968 -0
  10. package/dist/impls/health/provider-normalizers.d.ts +28 -0
  11. package/dist/impls/health/provider-normalizers.js +287 -0
  12. package/dist/impls/health/providers.d.ts +2 -39
  13. package/dist/impls/health/providers.js +895 -184
  14. package/dist/impls/health-provider-factory.js +1009 -196
  15. package/dist/impls/index.d.ts +6 -0
  16. package/dist/impls/index.js +1950 -278
  17. package/dist/impls/messaging-github.d.ts +17 -0
  18. package/dist/impls/messaging-github.js +110 -0
  19. package/dist/impls/messaging-slack.d.ts +14 -0
  20. package/dist/impls/messaging-slack.js +80 -0
  21. package/dist/impls/messaging-whatsapp-meta.d.ts +13 -0
  22. package/dist/impls/messaging-whatsapp-meta.js +52 -0
  23. package/dist/impls/messaging-whatsapp-twilio.d.ts +13 -0
  24. package/dist/impls/messaging-whatsapp-twilio.js +82 -0
  25. package/dist/impls/mistral-conversational.d.ts +23 -0
  26. package/dist/impls/mistral-conversational.js +476 -0
  27. package/dist/impls/mistral-conversational.session.d.ts +32 -0
  28. package/dist/impls/mistral-conversational.session.js +206 -0
  29. package/dist/impls/mistral-stt.d.ts +17 -0
  30. package/dist/impls/mistral-stt.js +167 -0
  31. package/dist/impls/provider-factory.d.ts +5 -1
  32. package/dist/impls/provider-factory.js +1943 -277
  33. package/dist/impls/stripe-payments.js +1 -1
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.js +1953 -278
  36. package/dist/messaging.d.ts +1 -0
  37. package/dist/messaging.js +3 -0
  38. package/dist/node/impls/async-event-queue.js +46 -0
  39. package/dist/node/impls/health/base-health-provider.js +506 -156
  40. package/dist/node/impls/health/hybrid-health-providers.js +1087 -0
  41. package/dist/node/impls/health/official-health-providers.js +967 -0
  42. package/dist/node/impls/health/provider-normalizers.js +286 -0
  43. package/dist/node/impls/health/providers.js +895 -184
  44. package/dist/node/impls/health-provider-factory.js +1009 -196
  45. package/dist/node/impls/index.js +1950 -278
  46. package/dist/node/impls/messaging-github.js +109 -0
  47. package/dist/node/impls/messaging-slack.js +79 -0
  48. package/dist/node/impls/messaging-whatsapp-meta.js +51 -0
  49. package/dist/node/impls/messaging-whatsapp-twilio.js +81 -0
  50. package/dist/node/impls/mistral-conversational.js +475 -0
  51. package/dist/node/impls/mistral-conversational.session.js +205 -0
  52. package/dist/node/impls/mistral-stt.js +166 -0
  53. package/dist/node/impls/provider-factory.js +1943 -277
  54. package/dist/node/impls/stripe-payments.js +1 -1
  55. package/dist/node/index.js +1953 -278
  56. package/dist/node/messaging.js +2 -0
  57. package/package.json +156 -12
@@ -1,4 +1,283 @@
1
+ // src/impls/health/provider-normalizers.ts
2
+ var DEFAULT_LIST_KEYS = [
3
+ "items",
4
+ "data",
5
+ "records",
6
+ "activities",
7
+ "workouts",
8
+ "sleep",
9
+ "biometrics",
10
+ "nutrition"
11
+ ];
12
+ function asRecord(value) {
13
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
14
+ return;
15
+ }
16
+ return value;
17
+ }
18
+ function asArray(value) {
19
+ return Array.isArray(value) ? value : undefined;
20
+ }
21
+ function readString(record, keys) {
22
+ if (!record)
23
+ return;
24
+ for (const key of keys) {
25
+ const value = record[key];
26
+ if (typeof value === "string" && value.trim().length > 0) {
27
+ return value;
28
+ }
29
+ }
30
+ return;
31
+ }
32
+ function readNumber(record, keys) {
33
+ if (!record)
34
+ return;
35
+ for (const key of keys) {
36
+ const value = record[key];
37
+ if (typeof value === "number" && Number.isFinite(value)) {
38
+ return value;
39
+ }
40
+ if (typeof value === "string" && value.trim().length > 0) {
41
+ const parsed = Number(value);
42
+ if (Number.isFinite(parsed)) {
43
+ return parsed;
44
+ }
45
+ }
46
+ }
47
+ return;
48
+ }
49
+ function readBoolean(record, keys) {
50
+ if (!record)
51
+ return;
52
+ for (const key of keys) {
53
+ const value = record[key];
54
+ if (typeof value === "boolean") {
55
+ return value;
56
+ }
57
+ }
58
+ return;
59
+ }
60
+ function extractList(payload, listKeys = DEFAULT_LIST_KEYS) {
61
+ const root = asRecord(payload);
62
+ if (!root) {
63
+ return asArray(payload)?.map((item) => asRecord(item)).filter((item) => Boolean(item)) ?? [];
64
+ }
65
+ for (const key of listKeys) {
66
+ const arrayValue = asArray(root[key]);
67
+ if (!arrayValue)
68
+ continue;
69
+ return arrayValue.map((item) => asRecord(item)).filter((item) => Boolean(item));
70
+ }
71
+ return [];
72
+ }
73
+ function extractPagination(payload) {
74
+ const root = asRecord(payload);
75
+ const nestedPagination = asRecord(root?.pagination);
76
+ const nextCursor = readString(nestedPagination, ["nextCursor", "next_cursor"]) ?? readString(root, [
77
+ "nextCursor",
78
+ "next_cursor",
79
+ "cursor",
80
+ "next_page_token"
81
+ ]);
82
+ const hasMore = readBoolean(nestedPagination, ["hasMore", "has_more"]) ?? readBoolean(root, ["hasMore", "has_more"]);
83
+ return {
84
+ nextCursor,
85
+ hasMore: hasMore ?? Boolean(nextCursor)
86
+ };
87
+ }
88
+ function toHealthActivity(item, context, fallbackType = "activity") {
89
+ const externalId = readString(item, ["external_id", "externalId", "uuid", "id"]) ?? `${context.providerKey}:${fallbackType}`;
90
+ const id = readString(item, ["id", "uuid", "workout_id"]) ?? `${context.providerKey}:activity:${externalId}`;
91
+ return {
92
+ id,
93
+ externalId,
94
+ tenantId: context.tenantId,
95
+ connectionId: context.connectionId ?? "unknown",
96
+ userId: readString(item, ["user_id", "userId", "athlete_id"]),
97
+ providerKey: context.providerKey,
98
+ activityType: readString(item, ["activity_type", "type", "sport_type", "sport"]) ?? fallbackType,
99
+ startedAt: readIsoDate(item, [
100
+ "started_at",
101
+ "start_time",
102
+ "start_date",
103
+ "created_at"
104
+ ]),
105
+ endedAt: readIsoDate(item, ["ended_at", "end_time"]),
106
+ durationSeconds: readNumber(item, [
107
+ "duration_seconds",
108
+ "duration",
109
+ "elapsed_time"
110
+ ]),
111
+ distanceMeters: readNumber(item, ["distance_meters", "distance"]),
112
+ caloriesKcal: readNumber(item, [
113
+ "calories_kcal",
114
+ "calories",
115
+ "active_kilocalories"
116
+ ]),
117
+ steps: readNumber(item, ["steps"])?.valueOf(),
118
+ metadata: item
119
+ };
120
+ }
121
+ function toHealthWorkout(item, context, fallbackType = "workout") {
122
+ const activity = toHealthActivity(item, context, fallbackType);
123
+ return {
124
+ id: activity.id,
125
+ externalId: activity.externalId,
126
+ tenantId: activity.tenantId,
127
+ connectionId: activity.connectionId,
128
+ userId: activity.userId,
129
+ providerKey: activity.providerKey,
130
+ workoutType: readString(item, [
131
+ "workout_type",
132
+ "sport_type",
133
+ "type",
134
+ "activity_type"
135
+ ]) ?? fallbackType,
136
+ startedAt: activity.startedAt,
137
+ endedAt: activity.endedAt,
138
+ durationSeconds: activity.durationSeconds,
139
+ distanceMeters: activity.distanceMeters,
140
+ caloriesKcal: activity.caloriesKcal,
141
+ averageHeartRateBpm: readNumber(item, [
142
+ "average_heart_rate",
143
+ "avg_hr",
144
+ "average_heart_rate_bpm"
145
+ ]),
146
+ maxHeartRateBpm: readNumber(item, [
147
+ "max_heart_rate",
148
+ "max_hr",
149
+ "max_heart_rate_bpm"
150
+ ]),
151
+ metadata: item
152
+ };
153
+ }
154
+ function toHealthSleep(item, context) {
155
+ const externalId = readString(item, ["external_id", "externalId", "uuid", "id"]) ?? `${context.providerKey}:sleep`;
156
+ const id = readString(item, ["id", "uuid"]) ?? `${context.providerKey}:sleep:${externalId}`;
157
+ const startedAt = readIsoDate(item, ["started_at", "start_time", "bedtime_start", "start"]) ?? new Date(0).toISOString();
158
+ const endedAt = readIsoDate(item, ["ended_at", "end_time", "bedtime_end", "end"]) ?? startedAt;
159
+ return {
160
+ id,
161
+ externalId,
162
+ tenantId: context.tenantId,
163
+ connectionId: context.connectionId ?? "unknown",
164
+ userId: readString(item, ["user_id", "userId"]),
165
+ providerKey: context.providerKey,
166
+ startedAt,
167
+ endedAt,
168
+ durationSeconds: readNumber(item, [
169
+ "duration_seconds",
170
+ "duration",
171
+ "total_sleep_duration"
172
+ ]),
173
+ deepSleepSeconds: readNumber(item, [
174
+ "deep_sleep_seconds",
175
+ "deep_sleep_duration"
176
+ ]),
177
+ lightSleepSeconds: readNumber(item, [
178
+ "light_sleep_seconds",
179
+ "light_sleep_duration"
180
+ ]),
181
+ remSleepSeconds: readNumber(item, [
182
+ "rem_sleep_seconds",
183
+ "rem_sleep_duration"
184
+ ]),
185
+ awakeSeconds: readNumber(item, ["awake_seconds", "awake_time"]),
186
+ sleepScore: readNumber(item, ["sleep_score", "score"]),
187
+ metadata: item
188
+ };
189
+ }
190
+ function toHealthBiometric(item, context, metricTypeFallback = "metric") {
191
+ const externalId = readString(item, ["external_id", "externalId", "uuid", "id"]) ?? `${context.providerKey}:biometric`;
192
+ const id = readString(item, ["id", "uuid"]) ?? `${context.providerKey}:biometric:${externalId}`;
193
+ return {
194
+ id,
195
+ externalId,
196
+ tenantId: context.tenantId,
197
+ connectionId: context.connectionId ?? "unknown",
198
+ userId: readString(item, ["user_id", "userId"]),
199
+ providerKey: context.providerKey,
200
+ metricType: readString(item, ["metric_type", "metric", "type", "name"]) ?? metricTypeFallback,
201
+ value: readNumber(item, ["value", "score", "measurement"]) ?? 0,
202
+ unit: readString(item, ["unit"]),
203
+ measuredAt: readIsoDate(item, ["measured_at", "timestamp", "created_at"]) ?? new Date().toISOString(),
204
+ metadata: item
205
+ };
206
+ }
207
+ function toHealthNutrition(item, context) {
208
+ const externalId = readString(item, ["external_id", "externalId", "uuid", "id"]) ?? `${context.providerKey}:nutrition`;
209
+ const id = readString(item, ["id", "uuid"]) ?? `${context.providerKey}:nutrition:${externalId}`;
210
+ return {
211
+ id,
212
+ externalId,
213
+ tenantId: context.tenantId,
214
+ connectionId: context.connectionId ?? "unknown",
215
+ userId: readString(item, ["user_id", "userId"]),
216
+ providerKey: context.providerKey,
217
+ loggedAt: readIsoDate(item, ["logged_at", "created_at", "date", "timestamp"]) ?? new Date().toISOString(),
218
+ caloriesKcal: readNumber(item, ["calories_kcal", "calories"]),
219
+ proteinGrams: readNumber(item, ["protein_grams", "protein"]),
220
+ carbsGrams: readNumber(item, ["carbs_grams", "carbs"]),
221
+ fatGrams: readNumber(item, ["fat_grams", "fat"]),
222
+ fiberGrams: readNumber(item, ["fiber_grams", "fiber"]),
223
+ hydrationMl: readNumber(item, ["hydration_ml", "water_ml", "water"]),
224
+ metadata: item
225
+ };
226
+ }
227
+ function toHealthConnectionStatus(payload, params, source) {
228
+ const record = asRecord(payload);
229
+ const rawStatus = readString(record, ["status", "connection_status", "health"]) ?? "healthy";
230
+ return {
231
+ tenantId: params.tenantId,
232
+ connectionId: params.connectionId,
233
+ status: rawStatus === "healthy" || rawStatus === "degraded" || rawStatus === "error" || rawStatus === "disconnected" ? rawStatus : "healthy",
234
+ source,
235
+ lastCheckedAt: readIsoDate(record, ["last_checked_at", "lastCheckedAt"]) ?? new Date().toISOString(),
236
+ errorCode: readString(record, ["error_code", "errorCode"]),
237
+ errorMessage: readString(record, ["error_message", "errorMessage"]),
238
+ metadata: asRecord(record?.metadata)
239
+ };
240
+ }
241
+ function toHealthWebhookEvent(payload, providerKey, verified) {
242
+ const record = asRecord(payload);
243
+ const entityType = readString(record, ["entity_type", "entityType", "type"]);
244
+ const normalizedEntityType = entityType === "activity" || entityType === "workout" || entityType === "sleep" || entityType === "biometric" || entityType === "nutrition" ? entityType : undefined;
245
+ return {
246
+ providerKey,
247
+ eventType: readString(record, ["event_type", "eventType", "event"]),
248
+ externalEntityId: readString(record, [
249
+ "external_entity_id",
250
+ "externalEntityId",
251
+ "entity_id",
252
+ "entityId",
253
+ "id"
254
+ ]),
255
+ entityType: normalizedEntityType,
256
+ receivedAt: new Date().toISOString(),
257
+ verified,
258
+ payload,
259
+ metadata: asRecord(record?.metadata)
260
+ };
261
+ }
262
+ function readIsoDate(record, keys) {
263
+ const value = readString(record, keys);
264
+ if (!value)
265
+ return;
266
+ const parsed = new Date(value);
267
+ if (Number.isNaN(parsed.getTime()))
268
+ return;
269
+ return parsed.toISOString();
270
+ }
271
+
1
272
  // src/impls/health/base-health-provider.ts
273
+ class HealthProviderCapabilityError extends Error {
274
+ code = "NOT_SUPPORTED";
275
+ constructor(message) {
276
+ super(message);
277
+ this.name = "HealthProviderCapabilityError";
278
+ }
279
+ }
280
+
2
281
  class BaseHealthProvider {
3
282
  providerKey;
4
283
  transport;
@@ -6,146 +285,191 @@ class BaseHealthProvider {
6
285
  mcpUrl;
7
286
  apiKey;
8
287
  accessToken;
288
+ refreshToken;
9
289
  mcpAccessToken;
10
290
  webhookSecret;
291
+ webhookSignatureHeader;
292
+ route;
293
+ aggregatorKey;
294
+ oauth;
11
295
  fetchFn;
12
296
  mcpRequestId = 0;
13
297
  constructor(options) {
14
298
  this.providerKey = options.providerKey;
15
299
  this.transport = options.transport;
16
- this.apiBaseUrl = options.apiBaseUrl ?? "https://api.example-health.local";
300
+ this.apiBaseUrl = options.apiBaseUrl;
17
301
  this.mcpUrl = options.mcpUrl;
18
302
  this.apiKey = options.apiKey;
19
303
  this.accessToken = options.accessToken;
304
+ this.refreshToken = options.oauth?.refreshToken;
20
305
  this.mcpAccessToken = options.mcpAccessToken;
21
306
  this.webhookSecret = options.webhookSecret;
307
+ this.webhookSignatureHeader = options.webhookSignatureHeader ?? "x-webhook-signature";
308
+ this.route = options.route ?? "primary";
309
+ this.aggregatorKey = options.aggregatorKey;
310
+ this.oauth = options.oauth ?? {};
22
311
  this.fetchFn = options.fetchFn ?? fetch;
23
312
  }
24
- async listActivities(params) {
25
- const result = await this.fetchList("activities", params);
26
- return {
27
- activities: result.items,
28
- nextCursor: result.nextCursor,
29
- hasMore: result.hasMore,
30
- source: this.currentSource()
31
- };
313
+ async listActivities(_params) {
314
+ throw this.unsupported("activities");
32
315
  }
33
- async listWorkouts(params) {
34
- const result = await this.fetchList("workouts", params);
316
+ async listWorkouts(_params) {
317
+ throw this.unsupported("workouts");
318
+ }
319
+ async listSleep(_params) {
320
+ throw this.unsupported("sleep");
321
+ }
322
+ async listBiometrics(_params) {
323
+ throw this.unsupported("biometrics");
324
+ }
325
+ async listNutrition(_params) {
326
+ throw this.unsupported("nutrition");
327
+ }
328
+ async getConnectionStatus(params) {
329
+ return this.fetchConnectionStatus(params, {
330
+ mcpTool: `${this.providerSlug()}_connection_status`
331
+ });
332
+ }
333
+ async syncActivities(params) {
334
+ return this.syncFromList(() => this.listActivities(params));
335
+ }
336
+ async syncWorkouts(params) {
337
+ return this.syncFromList(() => this.listWorkouts(params));
338
+ }
339
+ async syncSleep(params) {
340
+ return this.syncFromList(() => this.listSleep(params));
341
+ }
342
+ async syncBiometrics(params) {
343
+ return this.syncFromList(() => this.listBiometrics(params));
344
+ }
345
+ async syncNutrition(params) {
346
+ return this.syncFromList(() => this.listNutrition(params));
347
+ }
348
+ async parseWebhook(request) {
349
+ const payload = request.parsedBody ?? safeJsonParse(request.rawBody);
350
+ const verified = await this.verifyWebhook(request);
351
+ return toHealthWebhookEvent(payload, this.providerKey, verified);
352
+ }
353
+ async verifyWebhook(request) {
354
+ if (!this.webhookSecret)
355
+ return true;
356
+ const signature = readHeader(request.headers, this.webhookSignatureHeader);
357
+ return signature === this.webhookSecret;
358
+ }
359
+ async fetchActivities(params, config) {
360
+ const response = await this.fetchList(params, config);
35
361
  return {
36
- workouts: result.items,
37
- nextCursor: result.nextCursor,
38
- hasMore: result.hasMore,
362
+ activities: response.items,
363
+ nextCursor: response.nextCursor,
364
+ hasMore: response.hasMore,
39
365
  source: this.currentSource()
40
366
  };
41
367
  }
42
- async listSleep(params) {
43
- const result = await this.fetchList("sleep", params);
368
+ async fetchWorkouts(params, config) {
369
+ const response = await this.fetchList(params, config);
44
370
  return {
45
- sleep: result.items,
46
- nextCursor: result.nextCursor,
47
- hasMore: result.hasMore,
371
+ workouts: response.items,
372
+ nextCursor: response.nextCursor,
373
+ hasMore: response.hasMore,
48
374
  source: this.currentSource()
49
375
  };
50
376
  }
51
- async listBiometrics(params) {
52
- const result = await this.fetchList("biometrics", params);
377
+ async fetchSleep(params, config) {
378
+ const response = await this.fetchList(params, config);
53
379
  return {
54
- biometrics: result.items,
55
- nextCursor: result.nextCursor,
56
- hasMore: result.hasMore,
380
+ sleep: response.items,
381
+ nextCursor: response.nextCursor,
382
+ hasMore: response.hasMore,
57
383
  source: this.currentSource()
58
384
  };
59
385
  }
60
- async listNutrition(params) {
61
- const result = await this.fetchList("nutrition", params);
386
+ async fetchBiometrics(params, config) {
387
+ const response = await this.fetchList(params, config);
62
388
  return {
63
- nutrition: result.items,
64
- nextCursor: result.nextCursor,
65
- hasMore: result.hasMore,
389
+ biometrics: response.items,
390
+ nextCursor: response.nextCursor,
391
+ hasMore: response.hasMore,
66
392
  source: this.currentSource()
67
393
  };
68
394
  }
69
- async getConnectionStatus(params) {
70
- const payload = await this.fetchRecord("connection/status", params);
71
- const status = readString(payload, "status") ?? "healthy";
395
+ async fetchNutrition(params, config) {
396
+ const response = await this.fetchList(params, config);
72
397
  return {
73
- tenantId: params.tenantId,
74
- connectionId: params.connectionId,
75
- status: status === "healthy" || status === "degraded" || status === "error" || status === "disconnected" ? status : "healthy",
76
- source: this.currentSource(),
77
- lastCheckedAt: readString(payload, "lastCheckedAt") ?? new Date().toISOString(),
78
- errorCode: readString(payload, "errorCode"),
79
- errorMessage: readString(payload, "errorMessage"),
80
- metadata: asRecord(payload.metadata)
398
+ nutrition: response.items,
399
+ nextCursor: response.nextCursor,
400
+ hasMore: response.hasMore,
401
+ source: this.currentSource()
81
402
  };
82
403
  }
83
- async syncActivities(params) {
84
- return this.sync("activities", params);
85
- }
86
- async syncWorkouts(params) {
87
- return this.sync("workouts", params);
404
+ async fetchConnectionStatus(params, config) {
405
+ const payload = await this.fetchPayload(config, params);
406
+ return toHealthConnectionStatus(payload, params, this.currentSource());
88
407
  }
89
- async syncSleep(params) {
90
- return this.sync("sleep", params);
91
- }
92
- async syncBiometrics(params) {
93
- return this.sync("biometrics", params);
94
- }
95
- async syncNutrition(params) {
96
- return this.sync("nutrition", params);
97
- }
98
- async parseWebhook(request) {
99
- const payload = request.parsedBody ?? safeJsonParse(request.rawBody);
100
- const body = asRecord(payload);
408
+ currentSource() {
101
409
  return {
102
410
  providerKey: this.providerKey,
103
- eventType: readString(body, "eventType") ?? readString(body, "event"),
104
- externalEntityId: readString(body, "externalEntityId") ?? readString(body, "entityId"),
105
- entityType: normalizeEntityType(readString(body, "entityType") ?? readString(body, "type")),
106
- receivedAt: new Date().toISOString(),
107
- verified: await this.verifyWebhook(request),
108
- payload
411
+ transport: this.transport,
412
+ route: this.route,
413
+ aggregatorKey: this.aggregatorKey
109
414
  };
110
415
  }
111
- async verifyWebhook(request) {
112
- if (!this.webhookSecret) {
113
- return true;
114
- }
115
- const signature = readHeader(request.headers, "x-webhook-signature");
116
- return signature === this.webhookSecret;
416
+ providerSlug() {
417
+ return this.providerKey.replace("health.", "").replace(/-/g, "_");
117
418
  }
118
- async fetchList(resource, params) {
119
- const payload = await this.fetchRecord(resource, params);
120
- const items = asArray(payload.items) ?? asArray(payload[resource]) ?? asArray(payload.records) ?? [];
419
+ unsupported(capability) {
420
+ return new HealthProviderCapabilityError(`${this.providerKey} does not support ${capability}`);
421
+ }
422
+ async syncFromList(executor) {
423
+ const result = await executor();
424
+ const records = countResultRecords(result);
121
425
  return {
122
- items,
123
- nextCursor: readString(payload, "nextCursor") ?? readString(payload, "cursor"),
124
- hasMore: readBoolean(payload, "hasMore")
426
+ synced: records,
427
+ failed: 0,
428
+ nextCursor: undefined,
429
+ source: result.source
125
430
  };
126
431
  }
127
- async sync(resource, params) {
128
- const payload = await this.fetchRecord(`sync/${resource}`, params, "POST");
432
+ async fetchList(params, config) {
433
+ const payload = await this.fetchPayload(config, params);
434
+ const items = extractList(payload, config.listKeys).map((item) => config.mapItem(item, params)).filter((item) => Boolean(item));
435
+ const pagination = extractPagination(payload);
129
436
  return {
130
- synced: readNumber(payload, "synced") ?? 0,
131
- failed: readNumber(payload, "failed") ?? 0,
132
- nextCursor: readString(payload, "nextCursor"),
133
- errors: asArray(payload.errors)?.map((item) => String(item)),
134
- source: this.currentSource()
437
+ items,
438
+ nextCursor: pagination.nextCursor,
439
+ hasMore: pagination.hasMore
135
440
  };
136
441
  }
137
- async fetchRecord(resource, params, method = "GET") {
138
- if (this.transport.endsWith("mcp")) {
139
- return this.callMcpTool(resource, params);
442
+ async fetchPayload(config, params) {
443
+ const method = config.method ?? "GET";
444
+ const query = config.buildQuery?.(params);
445
+ const body = config.buildBody?.(params);
446
+ if (this.isMcpTransport()) {
447
+ return this.callMcpTool(config.mcpTool, {
448
+ ...query ?? {},
449
+ ...body ?? {}
450
+ });
451
+ }
452
+ if (!config.apiPath || !this.apiBaseUrl) {
453
+ throw new Error(`${this.providerKey} transport is missing an API path.`);
454
+ }
455
+ if (method === "POST") {
456
+ return this.requestApi(config.apiPath, "POST", undefined, body);
140
457
  }
141
- const url = new URL(`${this.apiBaseUrl.replace(/\/$/, "")}/${resource}`);
142
- if (method === "GET") {
143
- for (const [key, value] of Object.entries(params)) {
458
+ return this.requestApi(config.apiPath, "GET", query, undefined);
459
+ }
460
+ isMcpTransport() {
461
+ return this.transport.endsWith("mcp") || this.transport === "unofficial";
462
+ }
463
+ async requestApi(path, method, query, body) {
464
+ const url = new URL(path, ensureTrailingSlash(this.apiBaseUrl ?? ""));
465
+ if (query) {
466
+ for (const [key, value] of Object.entries(query)) {
144
467
  if (value == null)
145
468
  continue;
146
469
  if (Array.isArray(value)) {
147
- value.forEach((item) => {
148
- url.searchParams.append(key, String(item));
470
+ value.forEach((entry) => {
471
+ if (entry != null)
472
+ url.searchParams.append(key, String(entry));
149
473
  });
150
474
  continue;
151
475
  }
@@ -154,22 +478,22 @@ class BaseHealthProvider {
154
478
  }
155
479
  const response = await this.fetchFn(url, {
156
480
  method,
157
- headers: {
158
- "Content-Type": "application/json",
159
- ...this.accessToken || this.apiKey ? { Authorization: `Bearer ${this.accessToken ?? this.apiKey}` } : {}
160
- },
161
- body: method === "POST" ? JSON.stringify(params) : undefined
481
+ headers: this.authorizationHeaders(),
482
+ body: method === "POST" ? JSON.stringify(body ?? {}) : undefined
162
483
  });
163
- if (!response.ok) {
164
- const errorBody = await safeResponseText(response);
165
- throw new Error(`${this.providerKey} ${resource} failed (${response.status}): ${errorBody}`);
484
+ if (response.status === 401 && await this.refreshAccessToken()) {
485
+ const retryResponse = await this.fetchFn(url, {
486
+ method,
487
+ headers: this.authorizationHeaders(),
488
+ body: method === "POST" ? JSON.stringify(body ?? {}) : undefined
489
+ });
490
+ return this.readResponsePayload(retryResponse, path);
166
491
  }
167
- const data = await response.json();
168
- return asRecord(data) ?? {};
492
+ return this.readResponsePayload(response, path);
169
493
  }
170
- async callMcpTool(resource, params) {
494
+ async callMcpTool(toolName, args) {
171
495
  if (!this.mcpUrl) {
172
- return {};
496
+ throw new Error(`${this.providerKey} MCP URL is not configured.`);
173
497
  }
174
498
  const response = await this.fetchFn(this.mcpUrl, {
175
499
  method: "POST",
@@ -182,78 +506,103 @@ class BaseHealthProvider {
182
506
  id: ++this.mcpRequestId,
183
507
  method: "tools/call",
184
508
  params: {
185
- name: `${this.providerKey.replace("health.", "")}_${resource.replace(/\//g, "_")}`,
186
- arguments: params
509
+ name: toolName,
510
+ arguments: args
187
511
  }
188
512
  })
189
513
  });
190
- if (!response.ok) {
191
- const errorBody = await safeResponseText(response);
192
- throw new Error(`${this.providerKey} MCP ${resource} failed (${response.status}): ${errorBody}`);
514
+ const payload = await this.readResponsePayload(response, toolName);
515
+ const rpcEnvelope = asRecord(payload);
516
+ if (!rpcEnvelope)
517
+ return payload;
518
+ const rpcResult = asRecord(rpcEnvelope.result);
519
+ if (rpcResult) {
520
+ return rpcResult.structuredContent ?? rpcResult.data ?? rpcResult;
193
521
  }
194
- const rpcPayload = await response.json();
195
- const rpc = asRecord(rpcPayload);
196
- const result = asRecord(rpc?.result) ?? {};
197
- const structured = asRecord(result.structuredContent);
198
- if (structured)
199
- return structured;
200
- const data = asRecord(result.data);
201
- if (data)
202
- return data;
203
- return result;
522
+ return rpcEnvelope.structuredContent ?? rpcEnvelope.data ?? rpcEnvelope;
204
523
  }
205
- currentSource() {
524
+ authorizationHeaders() {
525
+ const token = this.accessToken ?? this.apiKey;
206
526
  return {
207
- providerKey: this.providerKey,
208
- transport: this.transport,
209
- route: "primary"
527
+ "Content-Type": "application/json",
528
+ ...token ? { Authorization: `Bearer ${token}` } : {}
210
529
  };
211
530
  }
212
- }
213
- function safeJsonParse(raw) {
214
- try {
215
- return JSON.parse(raw);
216
- } catch {
217
- return { rawBody: raw };
531
+ async refreshAccessToken() {
532
+ if (!this.oauth.tokenUrl || !this.refreshToken) {
533
+ return false;
534
+ }
535
+ const tokenUrl = new URL(this.oauth.tokenUrl);
536
+ const body = new URLSearchParams({
537
+ grant_type: "refresh_token",
538
+ refresh_token: this.refreshToken,
539
+ ...this.oauth.clientId ? { client_id: this.oauth.clientId } : {},
540
+ ...this.oauth.clientSecret ? { client_secret: this.oauth.clientSecret } : {}
541
+ });
542
+ const response = await this.fetchFn(tokenUrl, {
543
+ method: "POST",
544
+ headers: {
545
+ "Content-Type": "application/x-www-form-urlencoded"
546
+ },
547
+ body: body.toString()
548
+ });
549
+ if (!response.ok) {
550
+ return false;
551
+ }
552
+ const payload = await response.json();
553
+ this.accessToken = payload.access_token;
554
+ this.refreshToken = payload.refresh_token ?? this.refreshToken;
555
+ if (typeof payload.expires_in === "number") {
556
+ this.oauth.tokenExpiresAt = new Date(Date.now() + payload.expires_in * 1000).toISOString();
557
+ }
558
+ return Boolean(this.accessToken);
559
+ }
560
+ async readResponsePayload(response, context) {
561
+ if (!response.ok) {
562
+ const message = await safeReadText(response);
563
+ throw new Error(`${this.providerKey} request ${context} failed (${response.status}): ${message}`);
564
+ }
565
+ if (response.status === 204) {
566
+ return {};
567
+ }
568
+ return response.json();
218
569
  }
219
570
  }
220
571
  function readHeader(headers, key) {
221
- const match = Object.entries(headers).find(([headerKey]) => headerKey.toLowerCase() === key.toLowerCase());
222
- if (!match)
572
+ const target = key.toLowerCase();
573
+ const entry = Object.entries(headers).find(([headerKey]) => headerKey.toLowerCase() === target);
574
+ if (!entry)
223
575
  return;
224
- const value = match[1];
576
+ const value = entry[1];
225
577
  return Array.isArray(value) ? value[0] : value;
226
578
  }
227
- function normalizeEntityType(value) {
228
- if (!value)
229
- return;
230
- if (value === "activity" || value === "workout" || value === "sleep" || value === "biometric" || value === "nutrition") {
231
- return value;
232
- }
233
- return;
234
- }
235
- function asRecord(value) {
236
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
237
- return;
579
+ function countResultRecords(result) {
580
+ const listKeys = [
581
+ "activities",
582
+ "workouts",
583
+ "sleep",
584
+ "biometrics",
585
+ "nutrition"
586
+ ];
587
+ for (const key of listKeys) {
588
+ const value = result[key];
589
+ if (Array.isArray(value)) {
590
+ return value.length;
591
+ }
238
592
  }
239
- return value;
240
- }
241
- function asArray(value) {
242
- return Array.isArray(value) ? value : undefined;
593
+ return 0;
243
594
  }
244
- function readString(record, key) {
245
- const value = record?.[key];
246
- return typeof value === "string" ? value : undefined;
595
+ function ensureTrailingSlash(value) {
596
+ return value.endsWith("/") ? value : `${value}/`;
247
597
  }
248
- function readBoolean(record, key) {
249
- const value = record?.[key];
250
- return typeof value === "boolean" ? value : undefined;
251
- }
252
- function readNumber(record, key) {
253
- const value = record?.[key];
254
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
598
+ function safeJsonParse(raw) {
599
+ try {
600
+ return JSON.parse(raw);
601
+ } catch {
602
+ return { rawBody: raw };
603
+ }
255
604
  }
256
- async function safeResponseText(response) {
605
+ async function safeReadText(response) {
257
606
  try {
258
607
  return await response.text();
259
608
  } catch {
@@ -261,133 +610,530 @@ async function safeResponseText(response) {
261
610
  }
262
611
  }
263
612
 
264
- // src/impls/health/providers.ts
265
- function createProviderOptions(options, fallbackTransport) {
613
+ // src/impls/health/official-health-providers.ts
614
+ function buildSharedQuery(params) {
266
615
  return {
267
- ...options,
268
- transport: options.transport ?? fallbackTransport
616
+ tenantId: params.tenantId,
617
+ connectionId: params.connectionId,
618
+ userId: params.userId,
619
+ from: params.from,
620
+ to: params.to,
621
+ cursor: params.cursor,
622
+ pageSize: params.pageSize
623
+ };
624
+ }
625
+ function withMetricTypes(params) {
626
+ return {
627
+ ...buildSharedQuery(params),
628
+ metricTypes: params.metricTypes
269
629
  };
270
630
  }
271
631
 
272
632
  class OpenWearablesHealthProvider extends BaseHealthProvider {
633
+ upstreamProvider;
273
634
  constructor(options) {
274
635
  super({
275
- providerKey: "health.openwearables",
276
- ...createProviderOptions(options, "aggregator-api")
636
+ providerKey: options.providerKey ?? "health.openwearables",
637
+ apiBaseUrl: options.apiBaseUrl ?? "https://api.openwearables.io",
638
+ webhookSignatureHeader: "x-openwearables-signature",
639
+ ...options
277
640
  });
641
+ this.upstreamProvider = options.upstreamProvider;
642
+ }
643
+ async listActivities(params) {
644
+ return this.fetchActivities(params, {
645
+ apiPath: "/v1/activities",
646
+ mcpTool: "openwearables_list_activities",
647
+ buildQuery: (input) => this.withUpstreamProvider(buildSharedQuery(input)),
648
+ mapItem: (item, input) => toHealthActivity(item, this.context(input), "activity")
649
+ });
650
+ }
651
+ async listWorkouts(params) {
652
+ return this.fetchWorkouts(params, {
653
+ apiPath: "/v1/workouts",
654
+ mcpTool: "openwearables_list_workouts",
655
+ buildQuery: (input) => this.withUpstreamProvider(buildSharedQuery(input)),
656
+ mapItem: (item, input) => toHealthWorkout(item, this.context(input))
657
+ });
658
+ }
659
+ async listSleep(params) {
660
+ return this.fetchSleep(params, {
661
+ apiPath: "/v1/sleep",
662
+ mcpTool: "openwearables_list_sleep",
663
+ buildQuery: (input) => this.withUpstreamProvider(buildSharedQuery(input)),
664
+ mapItem: (item, input) => toHealthSleep(item, this.context(input))
665
+ });
666
+ }
667
+ async listBiometrics(params) {
668
+ return this.fetchBiometrics(params, {
669
+ apiPath: "/v1/biometrics",
670
+ mcpTool: "openwearables_list_biometrics",
671
+ buildQuery: (input) => this.withUpstreamProvider(withMetricTypes(input)),
672
+ mapItem: (item, input) => toHealthBiometric(item, this.context(input))
673
+ });
674
+ }
675
+ async listNutrition(params) {
676
+ return this.fetchNutrition(params, {
677
+ apiPath: "/v1/nutrition",
678
+ mcpTool: "openwearables_list_nutrition",
679
+ buildQuery: (input) => this.withUpstreamProvider(buildSharedQuery(input)),
680
+ mapItem: (item, input) => toHealthNutrition(item, this.context(input))
681
+ });
682
+ }
683
+ async getConnectionStatus(params) {
684
+ return this.fetchConnectionStatus(params, {
685
+ apiPath: `/v1/connections/${encodeURIComponent(params.connectionId)}/status`,
686
+ mcpTool: "openwearables_connection_status"
687
+ });
688
+ }
689
+ withUpstreamProvider(query) {
690
+ return {
691
+ ...query,
692
+ ...this.upstreamProvider ? { upstreamProvider: this.upstreamProvider } : {}
693
+ };
694
+ }
695
+ context(params) {
696
+ return {
697
+ tenantId: params.tenantId,
698
+ connectionId: params.connectionId,
699
+ providerKey: this.providerKey
700
+ };
278
701
  }
279
702
  }
280
703
 
281
- class WhoopHealthProvider extends BaseHealthProvider {
704
+ class AppleHealthBridgeProvider extends OpenWearablesHealthProvider {
282
705
  constructor(options) {
283
706
  super({
284
- providerKey: "health.whoop",
285
- ...createProviderOptions(options, "official-api")
707
+ ...options,
708
+ providerKey: "health.apple-health",
709
+ upstreamProvider: "apple-health"
286
710
  });
287
711
  }
288
712
  }
289
713
 
290
- class AppleHealthBridgeProvider extends BaseHealthProvider {
714
+ class WhoopHealthProvider extends BaseHealthProvider {
291
715
  constructor(options) {
292
716
  super({
293
- providerKey: "health.apple-health",
294
- ...createProviderOptions(options, "aggregator-api")
717
+ providerKey: "health.whoop",
718
+ apiBaseUrl: options.apiBaseUrl ?? "https://api.prod.whoop.com",
719
+ webhookSignatureHeader: "x-whoop-signature",
720
+ oauth: {
721
+ tokenUrl: options.oauth?.tokenUrl ?? "https://api.prod.whoop.com/oauth/oauth2/token",
722
+ ...options.oauth
723
+ },
724
+ ...options
725
+ });
726
+ }
727
+ async listActivities(params) {
728
+ return this.fetchActivities(params, {
729
+ apiPath: "/v2/activity/workout",
730
+ mcpTool: "whoop_list_activities",
731
+ buildQuery: buildSharedQuery,
732
+ mapItem: (item, input) => toHealthActivity(item, this.context(input), "workout")
733
+ });
734
+ }
735
+ async listWorkouts(params) {
736
+ return this.fetchWorkouts(params, {
737
+ apiPath: "/v2/activity/workout",
738
+ mcpTool: "whoop_list_workouts",
739
+ buildQuery: buildSharedQuery,
740
+ mapItem: (item, input) => toHealthWorkout(item, this.context(input))
741
+ });
742
+ }
743
+ async listSleep(params) {
744
+ return this.fetchSleep(params, {
745
+ apiPath: "/v2/activity/sleep",
746
+ mcpTool: "whoop_list_sleep",
747
+ buildQuery: buildSharedQuery,
748
+ mapItem: (item, input) => toHealthSleep(item, this.context(input))
749
+ });
750
+ }
751
+ async listBiometrics(params) {
752
+ return this.fetchBiometrics(params, {
753
+ apiPath: "/v2/recovery",
754
+ mcpTool: "whoop_list_biometrics",
755
+ buildQuery: withMetricTypes,
756
+ mapItem: (item, input) => toHealthBiometric(item, this.context(input), "recovery_score")
757
+ });
758
+ }
759
+ async listNutrition(_params) {
760
+ throw this.unsupported("nutrition");
761
+ }
762
+ async getConnectionStatus(params) {
763
+ return this.fetchConnectionStatus(params, {
764
+ apiPath: "/v2/user/profile/basic",
765
+ mcpTool: "whoop_connection_status"
295
766
  });
296
767
  }
768
+ context(params) {
769
+ return {
770
+ tenantId: params.tenantId,
771
+ connectionId: params.connectionId,
772
+ providerKey: this.providerKey
773
+ };
774
+ }
297
775
  }
298
776
 
299
777
  class OuraHealthProvider extends BaseHealthProvider {
300
778
  constructor(options) {
301
779
  super({
302
780
  providerKey: "health.oura",
303
- ...createProviderOptions(options, "official-api")
781
+ apiBaseUrl: options.apiBaseUrl ?? "https://api.ouraring.com",
782
+ webhookSignatureHeader: "x-oura-signature",
783
+ oauth: {
784
+ tokenUrl: options.oauth?.tokenUrl ?? "https://api.ouraring.com/oauth/token",
785
+ ...options.oauth
786
+ },
787
+ ...options
788
+ });
789
+ }
790
+ async listActivities(params) {
791
+ return this.fetchActivities(params, {
792
+ apiPath: "/v2/usercollection/daily_activity",
793
+ mcpTool: "oura_list_activities",
794
+ buildQuery: buildSharedQuery,
795
+ mapItem: (item, input) => toHealthActivity(item, this.context(input))
796
+ });
797
+ }
798
+ async listWorkouts(params) {
799
+ return this.fetchWorkouts(params, {
800
+ apiPath: "/v2/usercollection/workout",
801
+ mcpTool: "oura_list_workouts",
802
+ buildQuery: buildSharedQuery,
803
+ mapItem: (item, input) => toHealthWorkout(item, this.context(input))
804
+ });
805
+ }
806
+ async listSleep(params) {
807
+ return this.fetchSleep(params, {
808
+ apiPath: "/v2/usercollection/sleep",
809
+ mcpTool: "oura_list_sleep",
810
+ buildQuery: buildSharedQuery,
811
+ mapItem: (item, input) => toHealthSleep(item, this.context(input))
812
+ });
813
+ }
814
+ async listBiometrics(params) {
815
+ return this.fetchBiometrics(params, {
816
+ apiPath: "/v2/usercollection/daily_readiness",
817
+ mcpTool: "oura_list_biometrics",
818
+ buildQuery: withMetricTypes,
819
+ mapItem: (item, input) => toHealthBiometric(item, this.context(input), "readiness_score")
820
+ });
821
+ }
822
+ async listNutrition(_params) {
823
+ throw this.unsupported("nutrition");
824
+ }
825
+ async getConnectionStatus(params) {
826
+ return this.fetchConnectionStatus(params, {
827
+ apiPath: "/v2/usercollection/personal_info",
828
+ mcpTool: "oura_connection_status"
304
829
  });
305
830
  }
831
+ context(params) {
832
+ return {
833
+ tenantId: params.tenantId,
834
+ connectionId: params.connectionId,
835
+ providerKey: this.providerKey
836
+ };
837
+ }
306
838
  }
307
839
 
308
840
  class StravaHealthProvider extends BaseHealthProvider {
309
841
  constructor(options) {
310
842
  super({
311
843
  providerKey: "health.strava",
312
- ...createProviderOptions(options, "official-api")
844
+ apiBaseUrl: options.apiBaseUrl ?? "https://www.strava.com",
845
+ webhookSignatureHeader: "x-strava-signature",
846
+ oauth: {
847
+ tokenUrl: options.oauth?.tokenUrl ?? "https://www.strava.com/oauth/token",
848
+ ...options.oauth
849
+ },
850
+ ...options
851
+ });
852
+ }
853
+ async listActivities(params) {
854
+ return this.fetchActivities(params, {
855
+ apiPath: "/api/v3/athlete/activities",
856
+ mcpTool: "strava_list_activities",
857
+ buildQuery: buildSharedQuery,
858
+ mapItem: (item, input) => toHealthActivity(item, this.context(input))
859
+ });
860
+ }
861
+ async listWorkouts(params) {
862
+ return this.fetchWorkouts(params, {
863
+ apiPath: "/api/v3/athlete/activities",
864
+ mcpTool: "strava_list_workouts",
865
+ buildQuery: buildSharedQuery,
866
+ mapItem: (item, input) => toHealthWorkout(item, this.context(input))
313
867
  });
314
868
  }
869
+ async listSleep(_params) {
870
+ throw this.unsupported("sleep");
871
+ }
872
+ async listBiometrics(_params) {
873
+ throw this.unsupported("biometrics");
874
+ }
875
+ async listNutrition(_params) {
876
+ throw this.unsupported("nutrition");
877
+ }
878
+ async getConnectionStatus(params) {
879
+ return this.fetchConnectionStatus(params, {
880
+ apiPath: "/api/v3/athlete",
881
+ mcpTool: "strava_connection_status"
882
+ });
883
+ }
884
+ context(params) {
885
+ return {
886
+ tenantId: params.tenantId,
887
+ connectionId: params.connectionId,
888
+ providerKey: this.providerKey
889
+ };
890
+ }
315
891
  }
316
892
 
317
- class GarminHealthProvider extends BaseHealthProvider {
893
+ class FitbitHealthProvider extends BaseHealthProvider {
318
894
  constructor(options) {
319
895
  super({
320
- providerKey: "health.garmin",
321
- ...createProviderOptions(options, "official-api")
896
+ providerKey: "health.fitbit",
897
+ apiBaseUrl: options.apiBaseUrl ?? "https://api.fitbit.com",
898
+ webhookSignatureHeader: "x-fitbit-signature",
899
+ oauth: {
900
+ tokenUrl: options.oauth?.tokenUrl ?? "https://api.fitbit.com/oauth2/token",
901
+ ...options.oauth
902
+ },
903
+ ...options
904
+ });
905
+ }
906
+ async listActivities(params) {
907
+ return this.fetchActivities(params, {
908
+ apiPath: "/1/user/-/activities/list.json",
909
+ mcpTool: "fitbit_list_activities",
910
+ buildQuery: buildSharedQuery,
911
+ mapItem: (item, input) => toHealthActivity(item, this.context(input))
912
+ });
913
+ }
914
+ async listWorkouts(params) {
915
+ return this.fetchWorkouts(params, {
916
+ apiPath: "/1/user/-/activities/list.json",
917
+ mcpTool: "fitbit_list_workouts",
918
+ buildQuery: buildSharedQuery,
919
+ mapItem: (item, input) => toHealthWorkout(item, this.context(input))
920
+ });
921
+ }
922
+ async listSleep(params) {
923
+ return this.fetchSleep(params, {
924
+ apiPath: "/1.2/user/-/sleep/list.json",
925
+ mcpTool: "fitbit_list_sleep",
926
+ buildQuery: buildSharedQuery,
927
+ mapItem: (item, input) => toHealthSleep(item, this.context(input))
928
+ });
929
+ }
930
+ async listBiometrics(params) {
931
+ return this.fetchBiometrics(params, {
932
+ apiPath: "/1/user/-/body/log/weight/date/today/1m.json",
933
+ mcpTool: "fitbit_list_biometrics",
934
+ buildQuery: withMetricTypes,
935
+ mapItem: (item, input) => toHealthBiometric(item, this.context(input), "weight")
936
+ });
937
+ }
938
+ async listNutrition(params) {
939
+ return this.fetchNutrition(params, {
940
+ apiPath: "/1/user/-/foods/log/date/today.json",
941
+ mcpTool: "fitbit_list_nutrition",
942
+ buildQuery: buildSharedQuery,
943
+ mapItem: (item, input) => toHealthNutrition(item, this.context(input))
944
+ });
945
+ }
946
+ async getConnectionStatus(params) {
947
+ return this.fetchConnectionStatus(params, {
948
+ apiPath: "/1/user/-/profile.json",
949
+ mcpTool: "fitbit_connection_status"
322
950
  });
323
951
  }
952
+ context(params) {
953
+ return {
954
+ tenantId: params.tenantId,
955
+ connectionId: params.connectionId,
956
+ providerKey: this.providerKey
957
+ };
958
+ }
324
959
  }
325
960
 
326
- class FitbitHealthProvider extends BaseHealthProvider {
961
+ // src/impls/health/hybrid-health-providers.ts
962
+ var LIMITED_PROVIDER_SLUG = {
963
+ "health.garmin": "garmin",
964
+ "health.myfitnesspal": "myfitnesspal",
965
+ "health.eightsleep": "eightsleep",
966
+ "health.peloton": "peloton"
967
+ };
968
+ function buildSharedQuery2(params) {
969
+ return {
970
+ tenantId: params.tenantId,
971
+ connectionId: params.connectionId,
972
+ userId: params.userId,
973
+ from: params.from,
974
+ to: params.to,
975
+ cursor: params.cursor,
976
+ pageSize: params.pageSize
977
+ };
978
+ }
979
+
980
+ class GarminHealthProvider extends OpenWearablesHealthProvider {
327
981
  constructor(options) {
328
982
  super({
329
- providerKey: "health.fitbit",
330
- ...createProviderOptions(options, "official-api")
983
+ ...options,
984
+ providerKey: "health.garmin",
985
+ upstreamProvider: "garmin"
331
986
  });
332
987
  }
333
988
  }
334
989
 
335
- class MyFitnessPalHealthProvider extends BaseHealthProvider {
990
+ class MyFitnessPalHealthProvider extends OpenWearablesHealthProvider {
336
991
  constructor(options) {
337
992
  super({
993
+ ...options,
338
994
  providerKey: "health.myfitnesspal",
339
- ...createProviderOptions(options, "official-api")
995
+ upstreamProvider: "myfitnesspal"
340
996
  });
341
997
  }
342
998
  }
343
999
 
344
- class EightSleepHealthProvider extends BaseHealthProvider {
1000
+ class EightSleepHealthProvider extends OpenWearablesHealthProvider {
345
1001
  constructor(options) {
346
1002
  super({
1003
+ ...options,
347
1004
  providerKey: "health.eightsleep",
348
- ...createProviderOptions(options, "official-api")
1005
+ upstreamProvider: "eightsleep"
349
1006
  });
350
1007
  }
351
1008
  }
352
1009
 
353
- class PelotonHealthProvider extends BaseHealthProvider {
1010
+ class PelotonHealthProvider extends OpenWearablesHealthProvider {
354
1011
  constructor(options) {
355
1012
  super({
1013
+ ...options,
356
1014
  providerKey: "health.peloton",
357
- ...createProviderOptions(options, "official-api")
1015
+ upstreamProvider: "peloton"
358
1016
  });
359
1017
  }
360
1018
  }
361
1019
 
362
1020
  class UnofficialHealthAutomationProvider extends BaseHealthProvider {
1021
+ providerSlugValue;
363
1022
  constructor(options) {
364
1023
  super({
365
- ...createProviderOptions(options, "unofficial"),
366
- providerKey: options.providerKey
1024
+ ...options,
1025
+ providerKey: options.providerKey,
1026
+ webhookSignatureHeader: "x-unofficial-signature"
367
1027
  });
1028
+ this.providerSlugValue = LIMITED_PROVIDER_SLUG[options.providerKey];
1029
+ }
1030
+ async listActivities(params) {
1031
+ return this.fetchActivities(params, {
1032
+ mcpTool: `${this.providerSlugValue}_list_activities`,
1033
+ buildQuery: buildSharedQuery2,
1034
+ mapItem: (item, input) => toHealthActivity(item, this.context(input), "activity")
1035
+ });
1036
+ }
1037
+ async listWorkouts(params) {
1038
+ return this.fetchWorkouts(params, {
1039
+ mcpTool: `${this.providerSlugValue}_list_workouts`,
1040
+ buildQuery: buildSharedQuery2,
1041
+ mapItem: (item, input) => toHealthWorkout(item, this.context(input))
1042
+ });
1043
+ }
1044
+ async listSleep(params) {
1045
+ return this.fetchSleep(params, {
1046
+ mcpTool: `${this.providerSlugValue}_list_sleep`,
1047
+ buildQuery: buildSharedQuery2,
1048
+ mapItem: (item, input) => toHealthSleep(item, this.context(input))
1049
+ });
1050
+ }
1051
+ async listBiometrics(params) {
1052
+ return this.fetchBiometrics(params, {
1053
+ mcpTool: `${this.providerSlugValue}_list_biometrics`,
1054
+ buildQuery: (input) => ({
1055
+ ...buildSharedQuery2(input),
1056
+ metricTypes: input.metricTypes
1057
+ }),
1058
+ mapItem: (item, input) => toHealthBiometric(item, this.context(input))
1059
+ });
1060
+ }
1061
+ async listNutrition(params) {
1062
+ return this.fetchNutrition(params, {
1063
+ mcpTool: `${this.providerSlugValue}_list_nutrition`,
1064
+ buildQuery: buildSharedQuery2,
1065
+ mapItem: (item, input) => toHealthNutrition(item, this.context(input))
1066
+ });
1067
+ }
1068
+ async getConnectionStatus(params) {
1069
+ return this.fetchConnectionStatus(params, {
1070
+ mcpTool: `${this.providerSlugValue}_connection_status`
1071
+ });
1072
+ }
1073
+ context(params) {
1074
+ return {
1075
+ tenantId: params.tenantId,
1076
+ connectionId: params.connectionId,
1077
+ providerKey: this.providerKey
1078
+ };
368
1079
  }
369
1080
  }
370
-
371
1081
  // src/impls/health-provider-factory.ts
372
1082
  import {
373
1083
  isUnofficialHealthProviderAllowed,
374
1084
  resolveHealthStrategyOrder
375
1085
  } from "@contractspec/integration.runtime/runtime";
1086
+ var OFFICIAL_TRANSPORT_SUPPORTED_BY_PROVIDER = {
1087
+ "health.openwearables": false,
1088
+ "health.whoop": true,
1089
+ "health.apple-health": false,
1090
+ "health.oura": true,
1091
+ "health.strava": true,
1092
+ "health.garmin": false,
1093
+ "health.fitbit": true,
1094
+ "health.myfitnesspal": false,
1095
+ "health.eightsleep": false,
1096
+ "health.peloton": false
1097
+ };
1098
+ var UNOFFICIAL_SUPPORTED_BY_PROVIDER = {
1099
+ "health.openwearables": false,
1100
+ "health.whoop": false,
1101
+ "health.apple-health": false,
1102
+ "health.oura": false,
1103
+ "health.strava": false,
1104
+ "health.garmin": true,
1105
+ "health.fitbit": false,
1106
+ "health.myfitnesspal": true,
1107
+ "health.eightsleep": true,
1108
+ "health.peloton": true
1109
+ };
376
1110
  function createHealthProviderFromContext(context, secrets) {
377
1111
  const providerKey = context.spec.meta.key;
378
1112
  const config = toFactoryConfig(context.config);
379
1113
  const strategyOrder = buildStrategyOrder(config);
380
- const errors = [];
381
- for (const strategy of strategyOrder) {
382
- const provider = createHealthProviderForStrategy(providerKey, strategy, config, secrets);
1114
+ const attemptLogs = [];
1115
+ for (let index = 0;index < strategyOrder.length; index += 1) {
1116
+ const strategy = strategyOrder[index];
1117
+ if (!strategy)
1118
+ continue;
1119
+ const route = index === 0 ? "primary" : "fallback";
1120
+ if (!supportsStrategy(providerKey, strategy)) {
1121
+ attemptLogs.push(`${strategy}: unsupported by ${providerKey}`);
1122
+ continue;
1123
+ }
1124
+ if (!hasCredentialsForStrategy(strategy, config, secrets)) {
1125
+ attemptLogs.push(`${strategy}: missing credentials`);
1126
+ continue;
1127
+ }
1128
+ const provider = createHealthProviderForStrategy(providerKey, strategy, route, config, secrets);
383
1129
  if (provider) {
384
1130
  return provider;
385
1131
  }
386
- errors.push(`${strategy}: not available`);
1132
+ attemptLogs.push(`${strategy}: not available`);
387
1133
  }
388
- throw new Error(`Unable to resolve health provider for ${providerKey}. Strategies attempted: ${errors.join(", ")}.`);
1134
+ throw new Error(`Unable to resolve health provider for ${providerKey}. Strategies attempted: ${attemptLogs.join(", ")}.`);
389
1135
  }
390
- function createHealthProviderForStrategy(providerKey, strategy, config, secrets) {
1136
+ function createHealthProviderForStrategy(providerKey, strategy, route, config, secrets) {
391
1137
  const options = {
392
1138
  transport: strategy,
393
1139
  apiBaseUrl: config.apiBaseUrl,
@@ -395,10 +1141,21 @@ function createHealthProviderForStrategy(providerKey, strategy, config, secrets)
395
1141
  apiKey: getSecretString(secrets, "apiKey"),
396
1142
  accessToken: getSecretString(secrets, "accessToken"),
397
1143
  mcpAccessToken: getSecretString(secrets, "mcpAccessToken"),
398
- webhookSecret: getSecretString(secrets, "webhookSecret")
1144
+ webhookSecret: getSecretString(secrets, "webhookSecret"),
1145
+ route,
1146
+ oauth: {
1147
+ tokenUrl: config.oauthTokenUrl,
1148
+ refreshToken: getSecretString(secrets, "refreshToken"),
1149
+ clientId: getSecretString(secrets, "clientId"),
1150
+ clientSecret: getSecretString(secrets, "clientSecret"),
1151
+ tokenExpiresAt: getSecretString(secrets, "tokenExpiresAt")
1152
+ }
399
1153
  };
400
1154
  if (strategy === "aggregator-api" || strategy === "aggregator-mcp") {
401
- return new OpenWearablesHealthProvider(options);
1155
+ return createAggregatorProvider(providerKey, {
1156
+ ...options,
1157
+ aggregatorKey: "health.openwearables"
1158
+ });
402
1159
  }
403
1160
  if (strategy === "unofficial") {
404
1161
  if (!isUnofficialHealthProviderAllowed(providerKey, config)) {
@@ -420,6 +1177,31 @@ function createHealthProviderForStrategy(providerKey, strategy, config, secrets)
420
1177
  }
421
1178
  return createOfficialProvider(providerKey, options);
422
1179
  }
1180
+ function createAggregatorProvider(providerKey, options) {
1181
+ if (providerKey === "health.apple-health") {
1182
+ return new AppleHealthBridgeProvider(options);
1183
+ }
1184
+ if (providerKey === "health.garmin") {
1185
+ return new GarminHealthProvider(options);
1186
+ }
1187
+ if (providerKey === "health.myfitnesspal") {
1188
+ return new MyFitnessPalHealthProvider(options);
1189
+ }
1190
+ if (providerKey === "health.eightsleep") {
1191
+ return new EightSleepHealthProvider(options);
1192
+ }
1193
+ if (providerKey === "health.peloton") {
1194
+ return new PelotonHealthProvider(options);
1195
+ }
1196
+ if (providerKey === "health.openwearables") {
1197
+ return new OpenWearablesHealthProvider(options);
1198
+ }
1199
+ return new OpenWearablesHealthProvider({
1200
+ ...options,
1201
+ providerKey,
1202
+ upstreamProvider: providerKey.replace("health.", "")
1203
+ });
1204
+ }
423
1205
  function createOfficialProvider(providerKey, options) {
424
1206
  switch (providerKey) {
425
1207
  case "health.openwearables":
@@ -437,11 +1219,20 @@ function createOfficialProvider(providerKey, options) {
437
1219
  case "health.fitbit":
438
1220
  return new FitbitHealthProvider(options);
439
1221
  case "health.myfitnesspal":
440
- return new MyFitnessPalHealthProvider(options);
1222
+ return new MyFitnessPalHealthProvider({
1223
+ ...options,
1224
+ transport: "aggregator-api"
1225
+ });
441
1226
  case "health.eightsleep":
442
- return new EightSleepHealthProvider(options);
1227
+ return new EightSleepHealthProvider({
1228
+ ...options,
1229
+ transport: "aggregator-api"
1230
+ });
443
1231
  case "health.peloton":
444
- return new PelotonHealthProvider(options);
1232
+ return new PelotonHealthProvider({
1233
+ ...options,
1234
+ transport: "aggregator-api"
1235
+ });
445
1236
  default:
446
1237
  throw new Error(`Unsupported health provider key: ${providerKey}`);
447
1238
  }
@@ -454,6 +1245,7 @@ function toFactoryConfig(config) {
454
1245
  return {
455
1246
  apiBaseUrl: asString(record.apiBaseUrl),
456
1247
  mcpUrl: asString(record.mcpUrl),
1248
+ oauthTokenUrl: asString(record.oauthTokenUrl),
457
1249
  defaultTransport: normalizeTransport(record.defaultTransport),
458
1250
  strategyOrder: normalizeTransportArray(record.strategyOrder),
459
1251
  allowUnofficial: typeof record.allowUnofficial === "boolean" ? record.allowUnofficial : false,
@@ -482,6 +1274,27 @@ function normalizeTransportArray(value) {
482
1274
  const transports = value.map((item) => normalizeTransport(item)).filter((item) => Boolean(item));
483
1275
  return transports.length > 0 ? transports : undefined;
484
1276
  }
1277
+ function supportsStrategy(providerKey, strategy) {
1278
+ if (strategy === "official-api" || strategy === "official-mcp") {
1279
+ return OFFICIAL_TRANSPORT_SUPPORTED_BY_PROVIDER[providerKey];
1280
+ }
1281
+ if (strategy === "unofficial") {
1282
+ return UNOFFICIAL_SUPPORTED_BY_PROVIDER[providerKey];
1283
+ }
1284
+ return true;
1285
+ }
1286
+ function hasCredentialsForStrategy(strategy, config, secrets) {
1287
+ const hasApiCredential = Boolean(getSecretString(secrets, "accessToken")) || Boolean(getSecretString(secrets, "apiKey"));
1288
+ const hasMcpCredential = Boolean(getSecretString(secrets, "mcpAccessToken")) || hasApiCredential;
1289
+ if (strategy === "official-api" || strategy === "aggregator-api") {
1290
+ return hasApiCredential;
1291
+ }
1292
+ if (strategy === "official-mcp" || strategy === "aggregator-mcp") {
1293
+ return Boolean(config.mcpUrl) && hasMcpCredential;
1294
+ }
1295
+ const hasAutomationCredential = hasMcpCredential || Boolean(getSecretString(secrets, "username")) && Boolean(getSecretString(secrets, "password"));
1296
+ return Boolean(config.mcpUrl) && hasAutomationCredential;
1297
+ }
485
1298
  function getSecretString(secrets, key) {
486
1299
  const value = secrets[key];
487
1300
  return typeof value === "string" && value.trim().length > 0 ? value : undefined;