@contractspec/integration.providers-impls 2.10.0 → 3.1.1

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