@contractspec/integration.providers-impls 2.9.0 → 2.10.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.
@@ -0,0 +1,265 @@
1
+ // src/impls/health/base-health-provider.ts
2
+ class BaseHealthProvider {
3
+ providerKey;
4
+ transport;
5
+ apiBaseUrl;
6
+ mcpUrl;
7
+ apiKey;
8
+ accessToken;
9
+ mcpAccessToken;
10
+ webhookSecret;
11
+ fetchFn;
12
+ mcpRequestId = 0;
13
+ constructor(options) {
14
+ this.providerKey = options.providerKey;
15
+ this.transport = options.transport;
16
+ this.apiBaseUrl = options.apiBaseUrl ?? "https://api.example-health.local";
17
+ this.mcpUrl = options.mcpUrl;
18
+ this.apiKey = options.apiKey;
19
+ this.accessToken = options.accessToken;
20
+ this.mcpAccessToken = options.mcpAccessToken;
21
+ this.webhookSecret = options.webhookSecret;
22
+ this.fetchFn = options.fetchFn ?? fetch;
23
+ }
24
+ async listActivities(params) {
25
+ const result = await this.fetchList("activities", params);
26
+ return {
27
+ activities: result.items,
28
+ nextCursor: result.nextCursor,
29
+ hasMore: result.hasMore,
30
+ source: this.currentSource()
31
+ };
32
+ }
33
+ async listWorkouts(params) {
34
+ const result = await this.fetchList("workouts", params);
35
+ return {
36
+ workouts: result.items,
37
+ nextCursor: result.nextCursor,
38
+ hasMore: result.hasMore,
39
+ source: this.currentSource()
40
+ };
41
+ }
42
+ async listSleep(params) {
43
+ const result = await this.fetchList("sleep", params);
44
+ return {
45
+ sleep: result.items,
46
+ nextCursor: result.nextCursor,
47
+ hasMore: result.hasMore,
48
+ source: this.currentSource()
49
+ };
50
+ }
51
+ async listBiometrics(params) {
52
+ const result = await this.fetchList("biometrics", params);
53
+ return {
54
+ biometrics: result.items,
55
+ nextCursor: result.nextCursor,
56
+ hasMore: result.hasMore,
57
+ source: this.currentSource()
58
+ };
59
+ }
60
+ async listNutrition(params) {
61
+ const result = await this.fetchList("nutrition", params);
62
+ return {
63
+ nutrition: result.items,
64
+ nextCursor: result.nextCursor,
65
+ hasMore: result.hasMore,
66
+ source: this.currentSource()
67
+ };
68
+ }
69
+ async getConnectionStatus(params) {
70
+ const payload = await this.fetchRecord("connection/status", params);
71
+ const status = readString(payload, "status") ?? "healthy";
72
+ return {
73
+ tenantId: params.tenantId,
74
+ connectionId: params.connectionId,
75
+ status: status === "healthy" || status === "degraded" || status === "error" || status === "disconnected" ? status : "healthy",
76
+ source: this.currentSource(),
77
+ lastCheckedAt: readString(payload, "lastCheckedAt") ?? new Date().toISOString(),
78
+ errorCode: readString(payload, "errorCode"),
79
+ errorMessage: readString(payload, "errorMessage"),
80
+ metadata: asRecord(payload.metadata)
81
+ };
82
+ }
83
+ async syncActivities(params) {
84
+ return this.sync("activities", params);
85
+ }
86
+ async syncWorkouts(params) {
87
+ return this.sync("workouts", params);
88
+ }
89
+ async syncSleep(params) {
90
+ return this.sync("sleep", params);
91
+ }
92
+ async syncBiometrics(params) {
93
+ return this.sync("biometrics", params);
94
+ }
95
+ async syncNutrition(params) {
96
+ return this.sync("nutrition", params);
97
+ }
98
+ async parseWebhook(request) {
99
+ const payload = request.parsedBody ?? safeJsonParse(request.rawBody);
100
+ const body = asRecord(payload);
101
+ return {
102
+ providerKey: this.providerKey,
103
+ eventType: readString(body, "eventType") ?? readString(body, "event"),
104
+ externalEntityId: readString(body, "externalEntityId") ?? readString(body, "entityId"),
105
+ entityType: normalizeEntityType(readString(body, "entityType") ?? readString(body, "type")),
106
+ receivedAt: new Date().toISOString(),
107
+ verified: await this.verifyWebhook(request),
108
+ payload
109
+ };
110
+ }
111
+ async verifyWebhook(request) {
112
+ if (!this.webhookSecret) {
113
+ return true;
114
+ }
115
+ const signature = readHeader(request.headers, "x-webhook-signature");
116
+ return signature === this.webhookSecret;
117
+ }
118
+ async fetchList(resource, params) {
119
+ const payload = await this.fetchRecord(resource, params);
120
+ const items = asArray(payload.items) ?? asArray(payload[resource]) ?? asArray(payload.records) ?? [];
121
+ return {
122
+ items,
123
+ nextCursor: readString(payload, "nextCursor") ?? readString(payload, "cursor"),
124
+ hasMore: readBoolean(payload, "hasMore")
125
+ };
126
+ }
127
+ async sync(resource, params) {
128
+ const payload = await this.fetchRecord(`sync/${resource}`, params, "POST");
129
+ return {
130
+ synced: readNumber(payload, "synced") ?? 0,
131
+ failed: readNumber(payload, "failed") ?? 0,
132
+ nextCursor: readString(payload, "nextCursor"),
133
+ errors: asArray(payload.errors)?.map((item) => String(item)),
134
+ source: this.currentSource()
135
+ };
136
+ }
137
+ async fetchRecord(resource, params, method = "GET") {
138
+ if (this.transport.endsWith("mcp")) {
139
+ return this.callMcpTool(resource, params);
140
+ }
141
+ const url = new URL(`${this.apiBaseUrl.replace(/\/$/, "")}/${resource}`);
142
+ if (method === "GET") {
143
+ for (const [key, value] of Object.entries(params)) {
144
+ if (value == null)
145
+ continue;
146
+ if (Array.isArray(value)) {
147
+ value.forEach((item) => {
148
+ url.searchParams.append(key, String(item));
149
+ });
150
+ continue;
151
+ }
152
+ url.searchParams.set(key, String(value));
153
+ }
154
+ }
155
+ const response = await this.fetchFn(url, {
156
+ method,
157
+ headers: {
158
+ "Content-Type": "application/json",
159
+ ...this.accessToken || this.apiKey ? { Authorization: `Bearer ${this.accessToken ?? this.apiKey}` } : {}
160
+ },
161
+ body: method === "POST" ? JSON.stringify(params) : undefined
162
+ });
163
+ if (!response.ok) {
164
+ const errorBody = await safeResponseText(response);
165
+ throw new Error(`${this.providerKey} ${resource} failed (${response.status}): ${errorBody}`);
166
+ }
167
+ const data = await response.json();
168
+ return asRecord(data) ?? {};
169
+ }
170
+ async callMcpTool(resource, params) {
171
+ if (!this.mcpUrl) {
172
+ return {};
173
+ }
174
+ const response = await this.fetchFn(this.mcpUrl, {
175
+ method: "POST",
176
+ headers: {
177
+ "Content-Type": "application/json",
178
+ ...this.mcpAccessToken ? { Authorization: `Bearer ${this.mcpAccessToken}` } : {}
179
+ },
180
+ body: JSON.stringify({
181
+ jsonrpc: "2.0",
182
+ id: ++this.mcpRequestId,
183
+ method: "tools/call",
184
+ params: {
185
+ name: `${this.providerKey.replace("health.", "")}_${resource.replace(/\//g, "_")}`,
186
+ arguments: params
187
+ }
188
+ })
189
+ });
190
+ if (!response.ok) {
191
+ const errorBody = await safeResponseText(response);
192
+ throw new Error(`${this.providerKey} MCP ${resource} failed (${response.status}): ${errorBody}`);
193
+ }
194
+ const rpcPayload = await response.json();
195
+ const rpc = asRecord(rpcPayload);
196
+ const result = asRecord(rpc?.result) ?? {};
197
+ const structured = asRecord(result.structuredContent);
198
+ if (structured)
199
+ return structured;
200
+ const data = asRecord(result.data);
201
+ if (data)
202
+ return data;
203
+ return result;
204
+ }
205
+ currentSource() {
206
+ return {
207
+ providerKey: this.providerKey,
208
+ transport: this.transport,
209
+ route: "primary"
210
+ };
211
+ }
212
+ }
213
+ function safeJsonParse(raw) {
214
+ try {
215
+ return JSON.parse(raw);
216
+ } catch {
217
+ return { rawBody: raw };
218
+ }
219
+ }
220
+ function readHeader(headers, key) {
221
+ const match = Object.entries(headers).find(([headerKey]) => headerKey.toLowerCase() === key.toLowerCase());
222
+ if (!match)
223
+ return;
224
+ const value = match[1];
225
+ return Array.isArray(value) ? value[0] : value;
226
+ }
227
+ function normalizeEntityType(value) {
228
+ if (!value)
229
+ return;
230
+ if (value === "activity" || value === "workout" || value === "sleep" || value === "biometric" || value === "nutrition") {
231
+ return value;
232
+ }
233
+ return;
234
+ }
235
+ function asRecord(value) {
236
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
237
+ return;
238
+ }
239
+ return value;
240
+ }
241
+ function asArray(value) {
242
+ return Array.isArray(value) ? value : undefined;
243
+ }
244
+ function readString(record, key) {
245
+ const value = record?.[key];
246
+ return typeof value === "string" ? value : undefined;
247
+ }
248
+ function readBoolean(record, key) {
249
+ const value = record?.[key];
250
+ return typeof value === "boolean" ? value : undefined;
251
+ }
252
+ function readNumber(record, key) {
253
+ const value = record?.[key];
254
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
255
+ }
256
+ async function safeResponseText(response) {
257
+ try {
258
+ return await response.text();
259
+ } catch {
260
+ return response.statusText;
261
+ }
262
+ }
263
+ export {
264
+ BaseHealthProvider
265
+ };
@@ -0,0 +1,382 @@
1
+ // src/impls/health/base-health-provider.ts
2
+ class BaseHealthProvider {
3
+ providerKey;
4
+ transport;
5
+ apiBaseUrl;
6
+ mcpUrl;
7
+ apiKey;
8
+ accessToken;
9
+ mcpAccessToken;
10
+ webhookSecret;
11
+ fetchFn;
12
+ mcpRequestId = 0;
13
+ constructor(options) {
14
+ this.providerKey = options.providerKey;
15
+ this.transport = options.transport;
16
+ this.apiBaseUrl = options.apiBaseUrl ?? "https://api.example-health.local";
17
+ this.mcpUrl = options.mcpUrl;
18
+ this.apiKey = options.apiKey;
19
+ this.accessToken = options.accessToken;
20
+ this.mcpAccessToken = options.mcpAccessToken;
21
+ this.webhookSecret = options.webhookSecret;
22
+ this.fetchFn = options.fetchFn ?? fetch;
23
+ }
24
+ async listActivities(params) {
25
+ const result = await this.fetchList("activities", params);
26
+ return {
27
+ activities: result.items,
28
+ nextCursor: result.nextCursor,
29
+ hasMore: result.hasMore,
30
+ source: this.currentSource()
31
+ };
32
+ }
33
+ async listWorkouts(params) {
34
+ const result = await this.fetchList("workouts", params);
35
+ return {
36
+ workouts: result.items,
37
+ nextCursor: result.nextCursor,
38
+ hasMore: result.hasMore,
39
+ source: this.currentSource()
40
+ };
41
+ }
42
+ async listSleep(params) {
43
+ const result = await this.fetchList("sleep", params);
44
+ return {
45
+ sleep: result.items,
46
+ nextCursor: result.nextCursor,
47
+ hasMore: result.hasMore,
48
+ source: this.currentSource()
49
+ };
50
+ }
51
+ async listBiometrics(params) {
52
+ const result = await this.fetchList("biometrics", params);
53
+ return {
54
+ biometrics: result.items,
55
+ nextCursor: result.nextCursor,
56
+ hasMore: result.hasMore,
57
+ source: this.currentSource()
58
+ };
59
+ }
60
+ async listNutrition(params) {
61
+ const result = await this.fetchList("nutrition", params);
62
+ return {
63
+ nutrition: result.items,
64
+ nextCursor: result.nextCursor,
65
+ hasMore: result.hasMore,
66
+ source: this.currentSource()
67
+ };
68
+ }
69
+ async getConnectionStatus(params) {
70
+ const payload = await this.fetchRecord("connection/status", params);
71
+ const status = readString(payload, "status") ?? "healthy";
72
+ return {
73
+ tenantId: params.tenantId,
74
+ connectionId: params.connectionId,
75
+ status: status === "healthy" || status === "degraded" || status === "error" || status === "disconnected" ? status : "healthy",
76
+ source: this.currentSource(),
77
+ lastCheckedAt: readString(payload, "lastCheckedAt") ?? new Date().toISOString(),
78
+ errorCode: readString(payload, "errorCode"),
79
+ errorMessage: readString(payload, "errorMessage"),
80
+ metadata: asRecord(payload.metadata)
81
+ };
82
+ }
83
+ async syncActivities(params) {
84
+ return this.sync("activities", params);
85
+ }
86
+ async syncWorkouts(params) {
87
+ return this.sync("workouts", params);
88
+ }
89
+ async syncSleep(params) {
90
+ return this.sync("sleep", params);
91
+ }
92
+ async syncBiometrics(params) {
93
+ return this.sync("biometrics", params);
94
+ }
95
+ async syncNutrition(params) {
96
+ return this.sync("nutrition", params);
97
+ }
98
+ async parseWebhook(request) {
99
+ const payload = request.parsedBody ?? safeJsonParse(request.rawBody);
100
+ const body = asRecord(payload);
101
+ return {
102
+ providerKey: this.providerKey,
103
+ eventType: readString(body, "eventType") ?? readString(body, "event"),
104
+ externalEntityId: readString(body, "externalEntityId") ?? readString(body, "entityId"),
105
+ entityType: normalizeEntityType(readString(body, "entityType") ?? readString(body, "type")),
106
+ receivedAt: new Date().toISOString(),
107
+ verified: await this.verifyWebhook(request),
108
+ payload
109
+ };
110
+ }
111
+ async verifyWebhook(request) {
112
+ if (!this.webhookSecret) {
113
+ return true;
114
+ }
115
+ const signature = readHeader(request.headers, "x-webhook-signature");
116
+ return signature === this.webhookSecret;
117
+ }
118
+ async fetchList(resource, params) {
119
+ const payload = await this.fetchRecord(resource, params);
120
+ const items = asArray(payload.items) ?? asArray(payload[resource]) ?? asArray(payload.records) ?? [];
121
+ return {
122
+ items,
123
+ nextCursor: readString(payload, "nextCursor") ?? readString(payload, "cursor"),
124
+ hasMore: readBoolean(payload, "hasMore")
125
+ };
126
+ }
127
+ async sync(resource, params) {
128
+ const payload = await this.fetchRecord(`sync/${resource}`, params, "POST");
129
+ return {
130
+ synced: readNumber(payload, "synced") ?? 0,
131
+ failed: readNumber(payload, "failed") ?? 0,
132
+ nextCursor: readString(payload, "nextCursor"),
133
+ errors: asArray(payload.errors)?.map((item) => String(item)),
134
+ source: this.currentSource()
135
+ };
136
+ }
137
+ async fetchRecord(resource, params, method = "GET") {
138
+ if (this.transport.endsWith("mcp")) {
139
+ return this.callMcpTool(resource, params);
140
+ }
141
+ const url = new URL(`${this.apiBaseUrl.replace(/\/$/, "")}/${resource}`);
142
+ if (method === "GET") {
143
+ for (const [key, value] of Object.entries(params)) {
144
+ if (value == null)
145
+ continue;
146
+ if (Array.isArray(value)) {
147
+ value.forEach((item) => {
148
+ url.searchParams.append(key, String(item));
149
+ });
150
+ continue;
151
+ }
152
+ url.searchParams.set(key, String(value));
153
+ }
154
+ }
155
+ const response = await this.fetchFn(url, {
156
+ method,
157
+ headers: {
158
+ "Content-Type": "application/json",
159
+ ...this.accessToken || this.apiKey ? { Authorization: `Bearer ${this.accessToken ?? this.apiKey}` } : {}
160
+ },
161
+ body: method === "POST" ? JSON.stringify(params) : undefined
162
+ });
163
+ if (!response.ok) {
164
+ const errorBody = await safeResponseText(response);
165
+ throw new Error(`${this.providerKey} ${resource} failed (${response.status}): ${errorBody}`);
166
+ }
167
+ const data = await response.json();
168
+ return asRecord(data) ?? {};
169
+ }
170
+ async callMcpTool(resource, params) {
171
+ if (!this.mcpUrl) {
172
+ return {};
173
+ }
174
+ const response = await this.fetchFn(this.mcpUrl, {
175
+ method: "POST",
176
+ headers: {
177
+ "Content-Type": "application/json",
178
+ ...this.mcpAccessToken ? { Authorization: `Bearer ${this.mcpAccessToken}` } : {}
179
+ },
180
+ body: JSON.stringify({
181
+ jsonrpc: "2.0",
182
+ id: ++this.mcpRequestId,
183
+ method: "tools/call",
184
+ params: {
185
+ name: `${this.providerKey.replace("health.", "")}_${resource.replace(/\//g, "_")}`,
186
+ arguments: params
187
+ }
188
+ })
189
+ });
190
+ if (!response.ok) {
191
+ const errorBody = await safeResponseText(response);
192
+ throw new Error(`${this.providerKey} MCP ${resource} failed (${response.status}): ${errorBody}`);
193
+ }
194
+ const rpcPayload = await response.json();
195
+ const rpc = asRecord(rpcPayload);
196
+ const result = asRecord(rpc?.result) ?? {};
197
+ const structured = asRecord(result.structuredContent);
198
+ if (structured)
199
+ return structured;
200
+ const data = asRecord(result.data);
201
+ if (data)
202
+ return data;
203
+ return result;
204
+ }
205
+ currentSource() {
206
+ return {
207
+ providerKey: this.providerKey,
208
+ transport: this.transport,
209
+ route: "primary"
210
+ };
211
+ }
212
+ }
213
+ function safeJsonParse(raw) {
214
+ try {
215
+ return JSON.parse(raw);
216
+ } catch {
217
+ return { rawBody: raw };
218
+ }
219
+ }
220
+ function readHeader(headers, key) {
221
+ const match = Object.entries(headers).find(([headerKey]) => headerKey.toLowerCase() === key.toLowerCase());
222
+ if (!match)
223
+ return;
224
+ const value = match[1];
225
+ return Array.isArray(value) ? value[0] : value;
226
+ }
227
+ function normalizeEntityType(value) {
228
+ if (!value)
229
+ return;
230
+ if (value === "activity" || value === "workout" || value === "sleep" || value === "biometric" || value === "nutrition") {
231
+ return value;
232
+ }
233
+ return;
234
+ }
235
+ function asRecord(value) {
236
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
237
+ return;
238
+ }
239
+ return value;
240
+ }
241
+ function asArray(value) {
242
+ return Array.isArray(value) ? value : undefined;
243
+ }
244
+ function readString(record, key) {
245
+ const value = record?.[key];
246
+ return typeof value === "string" ? value : undefined;
247
+ }
248
+ function readBoolean(record, key) {
249
+ const value = record?.[key];
250
+ return typeof value === "boolean" ? value : undefined;
251
+ }
252
+ function readNumber(record, key) {
253
+ const value = record?.[key];
254
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
255
+ }
256
+ async function safeResponseText(response) {
257
+ try {
258
+ return await response.text();
259
+ } catch {
260
+ return response.statusText;
261
+ }
262
+ }
263
+
264
+ // src/impls/health/providers.ts
265
+ function createProviderOptions(options, fallbackTransport) {
266
+ return {
267
+ ...options,
268
+ transport: options.transport ?? fallbackTransport
269
+ };
270
+ }
271
+
272
+ class OpenWearablesHealthProvider extends BaseHealthProvider {
273
+ constructor(options) {
274
+ super({
275
+ providerKey: "health.openwearables",
276
+ ...createProviderOptions(options, "aggregator-api")
277
+ });
278
+ }
279
+ }
280
+
281
+ class WhoopHealthProvider extends BaseHealthProvider {
282
+ constructor(options) {
283
+ super({
284
+ providerKey: "health.whoop",
285
+ ...createProviderOptions(options, "official-api")
286
+ });
287
+ }
288
+ }
289
+
290
+ class AppleHealthBridgeProvider extends BaseHealthProvider {
291
+ constructor(options) {
292
+ super({
293
+ providerKey: "health.apple-health",
294
+ ...createProviderOptions(options, "aggregator-api")
295
+ });
296
+ }
297
+ }
298
+
299
+ class OuraHealthProvider extends BaseHealthProvider {
300
+ constructor(options) {
301
+ super({
302
+ providerKey: "health.oura",
303
+ ...createProviderOptions(options, "official-api")
304
+ });
305
+ }
306
+ }
307
+
308
+ class StravaHealthProvider extends BaseHealthProvider {
309
+ constructor(options) {
310
+ super({
311
+ providerKey: "health.strava",
312
+ ...createProviderOptions(options, "official-api")
313
+ });
314
+ }
315
+ }
316
+
317
+ class GarminHealthProvider extends BaseHealthProvider {
318
+ constructor(options) {
319
+ super({
320
+ providerKey: "health.garmin",
321
+ ...createProviderOptions(options, "official-api")
322
+ });
323
+ }
324
+ }
325
+
326
+ class FitbitHealthProvider extends BaseHealthProvider {
327
+ constructor(options) {
328
+ super({
329
+ providerKey: "health.fitbit",
330
+ ...createProviderOptions(options, "official-api")
331
+ });
332
+ }
333
+ }
334
+
335
+ class MyFitnessPalHealthProvider extends BaseHealthProvider {
336
+ constructor(options) {
337
+ super({
338
+ providerKey: "health.myfitnesspal",
339
+ ...createProviderOptions(options, "official-api")
340
+ });
341
+ }
342
+ }
343
+
344
+ class EightSleepHealthProvider extends BaseHealthProvider {
345
+ constructor(options) {
346
+ super({
347
+ providerKey: "health.eightsleep",
348
+ ...createProviderOptions(options, "official-api")
349
+ });
350
+ }
351
+ }
352
+
353
+ class PelotonHealthProvider extends BaseHealthProvider {
354
+ constructor(options) {
355
+ super({
356
+ providerKey: "health.peloton",
357
+ ...createProviderOptions(options, "official-api")
358
+ });
359
+ }
360
+ }
361
+
362
+ class UnofficialHealthAutomationProvider extends BaseHealthProvider {
363
+ constructor(options) {
364
+ super({
365
+ ...createProviderOptions(options, "unofficial"),
366
+ providerKey: options.providerKey
367
+ });
368
+ }
369
+ }
370
+ export {
371
+ WhoopHealthProvider,
372
+ UnofficialHealthAutomationProvider,
373
+ StravaHealthProvider,
374
+ PelotonHealthProvider,
375
+ OuraHealthProvider,
376
+ OpenWearablesHealthProvider,
377
+ MyFitnessPalHealthProvider,
378
+ GarminHealthProvider,
379
+ FitbitHealthProvider,
380
+ EightSleepHealthProvider,
381
+ AppleHealthBridgeProvider
382
+ };