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