@contractspec/integration.providers-impls 2.8.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.
package/README.md CHANGED
@@ -43,6 +43,59 @@ const factory = new IntegrationProviderFactory();
43
43
  const analytics = await factory.createAnalyticsProvider(context); // key: analytics.posthog
44
44
  ```
45
45
 
46
+ ## Health integrations
47
+
48
+ This package ships health/wearables providers routed by a transport strategy:
49
+
50
+ - Official provider APIs/MCP (Whoop, Apple Health bridge, Oura, Strava, Garmin, Fitbit)
51
+ - Aggregator API/MCP (Open Wearables)
52
+ - Unofficial automation fallback (opt-in and allow-list gated)
53
+
54
+ Factory usage:
55
+
56
+ ```ts
57
+ import { IntegrationProviderFactory } from "@contractspec/integration.providers-impls/impls/provider-factory";
58
+
59
+ const factory = new IntegrationProviderFactory();
60
+ const healthProvider = await factory.createHealthProvider(context); // key: health.*
61
+ ```
62
+
63
+ Connection config example:
64
+
65
+ ```json
66
+ {
67
+ "defaultTransport": "official-api",
68
+ "strategyOrder": [
69
+ "official-api",
70
+ "official-mcp",
71
+ "aggregator-api",
72
+ "aggregator-mcp",
73
+ "unofficial"
74
+ ],
75
+ "allowUnofficial": false,
76
+ "unofficialAllowList": ["health.peloton"],
77
+ "apiBaseUrl": "https://api.provider.example",
78
+ "mcpUrl": "https://mcp.provider.example"
79
+ }
80
+ ```
81
+
82
+ Secret payload example (`secretRef` target value):
83
+
84
+ ```json
85
+ {
86
+ "apiKey": "provider-api-key",
87
+ "accessToken": "oauth-access-token",
88
+ "mcpAccessToken": "mcp-access-token",
89
+ "webhookSecret": "webhook-signature-secret"
90
+ }
91
+ ```
92
+
93
+ Notes:
94
+
95
+ - Unofficial routing is disabled unless `allowUnofficial: true`.
96
+ - When `unofficialAllowList` is provided, only listed `health.*` keys can use unofficial routing.
97
+ - If a selected strategy is unavailable, the resolver falls through `strategyOrder`.
98
+
46
99
  ## Supabase integrations
47
100
 
48
101
  This package now ships two Supabase providers:
@@ -0,0 +1 @@
1
+ export * from '@contractspec/lib.contracts-integrations';
package/dist/health.js ADDED
@@ -0,0 +1,3 @@
1
+ // @bun
2
+ // src/health.ts
3
+ export * from "@contractspec/lib.contracts-integrations";
@@ -0,0 +1,47 @@
1
+ import type { HealthConnectionStatus, HealthDataSource, HealthListActivitiesParams, HealthListActivitiesResult, HealthListBiometricsParams, HealthListBiometricsResult, HealthListNutritionParams, HealthListNutritionResult, HealthListSleepParams, HealthListSleepResult, HealthListWorkoutsParams, HealthListWorkoutsResult, HealthProvider, HealthSyncRequest, HealthSyncResult, HealthWebhookEvent, HealthWebhookRequest } from '../../health';
2
+ import type { HealthTransportStrategy } from '@contractspec/integration.runtime/runtime';
3
+ export interface BaseHealthProviderOptions {
4
+ providerKey: string;
5
+ transport: HealthTransportStrategy;
6
+ apiBaseUrl?: string;
7
+ mcpUrl?: string;
8
+ apiKey?: string;
9
+ accessToken?: string;
10
+ mcpAccessToken?: string;
11
+ webhookSecret?: string;
12
+ fetchFn?: typeof fetch;
13
+ }
14
+ export declare class BaseHealthProvider implements HealthProvider {
15
+ private readonly providerKey;
16
+ private readonly transport;
17
+ private readonly apiBaseUrl;
18
+ private readonly mcpUrl?;
19
+ private readonly apiKey?;
20
+ private readonly accessToken?;
21
+ private readonly mcpAccessToken?;
22
+ private readonly webhookSecret?;
23
+ private readonly fetchFn;
24
+ private mcpRequestId;
25
+ constructor(options: BaseHealthProviderOptions);
26
+ listActivities(params: HealthListActivitiesParams): Promise<HealthListActivitiesResult>;
27
+ listWorkouts(params: HealthListWorkoutsParams): Promise<HealthListWorkoutsResult>;
28
+ listSleep(params: HealthListSleepParams): Promise<HealthListSleepResult>;
29
+ listBiometrics(params: HealthListBiometricsParams): Promise<HealthListBiometricsResult>;
30
+ listNutrition(params: HealthListNutritionParams): Promise<HealthListNutritionResult>;
31
+ getConnectionStatus(params: {
32
+ tenantId: string;
33
+ connectionId: string;
34
+ }): Promise<HealthConnectionStatus>;
35
+ syncActivities(params: HealthSyncRequest): Promise<HealthSyncResult>;
36
+ syncWorkouts(params: HealthSyncRequest): Promise<HealthSyncResult>;
37
+ syncSleep(params: HealthSyncRequest): Promise<HealthSyncResult>;
38
+ syncBiometrics(params: HealthSyncRequest): Promise<HealthSyncResult>;
39
+ syncNutrition(params: HealthSyncRequest): Promise<HealthSyncResult>;
40
+ parseWebhook(request: HealthWebhookRequest): Promise<HealthWebhookEvent>;
41
+ verifyWebhook(request: HealthWebhookRequest): Promise<boolean>;
42
+ private fetchList;
43
+ private sync;
44
+ private fetchRecord;
45
+ private callMcpTool;
46
+ protected currentSource(): HealthDataSource;
47
+ }
@@ -0,0 +1,266 @@
1
+ // @bun
2
+ // src/impls/health/base-health-provider.ts
3
+ class BaseHealthProvider {
4
+ providerKey;
5
+ transport;
6
+ apiBaseUrl;
7
+ mcpUrl;
8
+ apiKey;
9
+ accessToken;
10
+ mcpAccessToken;
11
+ webhookSecret;
12
+ fetchFn;
13
+ mcpRequestId = 0;
14
+ constructor(options) {
15
+ this.providerKey = options.providerKey;
16
+ this.transport = options.transport;
17
+ this.apiBaseUrl = options.apiBaseUrl ?? "https://api.example-health.local";
18
+ this.mcpUrl = options.mcpUrl;
19
+ this.apiKey = options.apiKey;
20
+ this.accessToken = options.accessToken;
21
+ this.mcpAccessToken = options.mcpAccessToken;
22
+ this.webhookSecret = options.webhookSecret;
23
+ this.fetchFn = options.fetchFn ?? fetch;
24
+ }
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
+ };
33
+ }
34
+ async listWorkouts(params) {
35
+ const result = await this.fetchList("workouts", params);
36
+ return {
37
+ workouts: result.items,
38
+ nextCursor: result.nextCursor,
39
+ hasMore: result.hasMore,
40
+ source: this.currentSource()
41
+ };
42
+ }
43
+ async listSleep(params) {
44
+ const result = await this.fetchList("sleep", params);
45
+ return {
46
+ sleep: result.items,
47
+ nextCursor: result.nextCursor,
48
+ hasMore: result.hasMore,
49
+ source: this.currentSource()
50
+ };
51
+ }
52
+ async listBiometrics(params) {
53
+ const result = await this.fetchList("biometrics", params);
54
+ return {
55
+ biometrics: result.items,
56
+ nextCursor: result.nextCursor,
57
+ hasMore: result.hasMore,
58
+ source: this.currentSource()
59
+ };
60
+ }
61
+ async listNutrition(params) {
62
+ const result = await this.fetchList("nutrition", params);
63
+ return {
64
+ nutrition: result.items,
65
+ nextCursor: result.nextCursor,
66
+ hasMore: result.hasMore,
67
+ source: this.currentSource()
68
+ };
69
+ }
70
+ async getConnectionStatus(params) {
71
+ const payload = await this.fetchRecord("connection/status", params);
72
+ const status = readString(payload, "status") ?? "healthy";
73
+ 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)
82
+ };
83
+ }
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);
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);
102
+ return {
103
+ 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
110
+ };
111
+ }
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;
118
+ }
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) ?? [];
122
+ return {
123
+ items,
124
+ nextCursor: readString(payload, "nextCursor") ?? readString(payload, "cursor"),
125
+ hasMore: readBoolean(payload, "hasMore")
126
+ };
127
+ }
128
+ async sync(resource, params) {
129
+ const payload = await this.fetchRecord(`sync/${resource}`, params, "POST");
130
+ 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()
136
+ };
137
+ }
138
+ async fetchRecord(resource, params, method = "GET") {
139
+ if (this.transport.endsWith("mcp")) {
140
+ return this.callMcpTool(resource, params);
141
+ }
142
+ const url = new URL(`${this.apiBaseUrl.replace(/\/$/, "")}/${resource}`);
143
+ if (method === "GET") {
144
+ for (const [key, value] of Object.entries(params)) {
145
+ if (value == null)
146
+ continue;
147
+ if (Array.isArray(value)) {
148
+ value.forEach((item) => {
149
+ url.searchParams.append(key, String(item));
150
+ });
151
+ continue;
152
+ }
153
+ url.searchParams.set(key, String(value));
154
+ }
155
+ }
156
+ const response = await this.fetchFn(url, {
157
+ 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
163
+ });
164
+ if (!response.ok) {
165
+ const errorBody = await safeResponseText(response);
166
+ throw new Error(`${this.providerKey} ${resource} failed (${response.status}): ${errorBody}`);
167
+ }
168
+ const data = await response.json();
169
+ return asRecord(data) ?? {};
170
+ }
171
+ async callMcpTool(resource, params) {
172
+ if (!this.mcpUrl) {
173
+ return {};
174
+ }
175
+ const response = await this.fetchFn(this.mcpUrl, {
176
+ method: "POST",
177
+ headers: {
178
+ "Content-Type": "application/json",
179
+ ...this.mcpAccessToken ? { Authorization: `Bearer ${this.mcpAccessToken}` } : {}
180
+ },
181
+ body: JSON.stringify({
182
+ jsonrpc: "2.0",
183
+ id: ++this.mcpRequestId,
184
+ method: "tools/call",
185
+ params: {
186
+ name: `${this.providerKey.replace("health.", "")}_${resource.replace(/\//g, "_")}`,
187
+ arguments: params
188
+ }
189
+ })
190
+ });
191
+ if (!response.ok) {
192
+ const errorBody = await safeResponseText(response);
193
+ throw new Error(`${this.providerKey} MCP ${resource} failed (${response.status}): ${errorBody}`);
194
+ }
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;
205
+ }
206
+ currentSource() {
207
+ return {
208
+ providerKey: this.providerKey,
209
+ transport: this.transport,
210
+ route: "primary"
211
+ };
212
+ }
213
+ }
214
+ function safeJsonParse(raw) {
215
+ try {
216
+ return JSON.parse(raw);
217
+ } catch {
218
+ return { rawBody: raw };
219
+ }
220
+ }
221
+ function readHeader(headers, key) {
222
+ const match = Object.entries(headers).find(([headerKey]) => headerKey.toLowerCase() === key.toLowerCase());
223
+ if (!match)
224
+ return;
225
+ const value = match[1];
226
+ return Array.isArray(value) ? value[0] : value;
227
+ }
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;
239
+ }
240
+ return value;
241
+ }
242
+ function asArray(value) {
243
+ return Array.isArray(value) ? value : undefined;
244
+ }
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;
256
+ }
257
+ async function safeResponseText(response) {
258
+ try {
259
+ return await response.text();
260
+ } catch {
261
+ return response.statusText;
262
+ }
263
+ }
264
+ export {
265
+ BaseHealthProvider
266
+ };
@@ -0,0 +1,39 @@
1
+ import { BaseHealthProvider, type BaseHealthProviderOptions } from './base-health-provider';
2
+ type ProviderOptions = Omit<BaseHealthProviderOptions, 'providerKey'>;
3
+ export declare class OpenWearablesHealthProvider extends BaseHealthProvider {
4
+ constructor(options: ProviderOptions);
5
+ }
6
+ export declare class WhoopHealthProvider extends BaseHealthProvider {
7
+ constructor(options: ProviderOptions);
8
+ }
9
+ export declare class AppleHealthBridgeProvider extends BaseHealthProvider {
10
+ constructor(options: ProviderOptions);
11
+ }
12
+ export declare class OuraHealthProvider extends BaseHealthProvider {
13
+ constructor(options: ProviderOptions);
14
+ }
15
+ export declare class StravaHealthProvider extends BaseHealthProvider {
16
+ constructor(options: ProviderOptions);
17
+ }
18
+ export declare class GarminHealthProvider extends BaseHealthProvider {
19
+ constructor(options: ProviderOptions);
20
+ }
21
+ export declare class FitbitHealthProvider extends BaseHealthProvider {
22
+ constructor(options: ProviderOptions);
23
+ }
24
+ export declare class MyFitnessPalHealthProvider extends BaseHealthProvider {
25
+ constructor(options: ProviderOptions);
26
+ }
27
+ export declare class EightSleepHealthProvider extends BaseHealthProvider {
28
+ constructor(options: ProviderOptions);
29
+ }
30
+ export declare class PelotonHealthProvider extends BaseHealthProvider {
31
+ constructor(options: ProviderOptions);
32
+ }
33
+ export interface UnofficialHealthAutomationProviderOptions extends ProviderOptions {
34
+ providerKey: 'health.myfitnesspal' | 'health.eightsleep' | 'health.peloton' | 'health.garmin';
35
+ }
36
+ export declare class UnofficialHealthAutomationProvider extends BaseHealthProvider {
37
+ constructor(options: UnofficialHealthAutomationProviderOptions);
38
+ }
39
+ export {};