@clipin/convex-wearables 0.0.2 → 0.0.3

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 (88) hide show
  1. package/dist/client/index.d.ts +9 -4
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js.map +1 -1
  4. package/dist/component/_generated/component.d.ts +50 -0
  5. package/dist/component/_generated/component.d.ts.map +1 -0
  6. package/dist/component/_generated/component.js +11 -0
  7. package/dist/component/_generated/component.js.map +1 -0
  8. package/dist/component/backfillJobs.d.ts +11 -11
  9. package/dist/component/connections.d.ts +9 -9
  10. package/dist/component/connections.d.ts.map +1 -1
  11. package/dist/component/connections.js +2 -0
  12. package/dist/component/connections.js.map +1 -1
  13. package/dist/component/dataPoints.d.ts +5 -5
  14. package/dist/component/events.d.ts +13 -13
  15. package/dist/component/garminBackfill.d.ts +2 -2
  16. package/dist/component/garminWebhooks.d.ts +2 -2
  17. package/dist/component/garminWebhooks.d.ts.map +1 -1
  18. package/dist/component/garminWebhooks.js +2 -0
  19. package/dist/component/garminWebhooks.js.map +1 -1
  20. package/dist/component/lifecycle.d.ts +1 -1
  21. package/dist/component/lifecycle.d.ts.map +1 -1
  22. package/dist/component/lifecycle.js +2 -0
  23. package/dist/component/lifecycle.js.map +1 -1
  24. package/dist/component/oauthStates.d.ts +3 -3
  25. package/dist/component/schema.d.ts +26 -26
  26. package/dist/component/sdkPush.d.ts +11 -11
  27. package/dist/component/summaries.d.ts +4 -4
  28. package/dist/component/syncJobs.d.ts +23 -23
  29. package/dist/component/syncWorkflow.d.ts +2 -2
  30. package/dist/test.d.ts +421 -0
  31. package/dist/test.d.ts.map +1 -0
  32. package/dist/test.js +17 -0
  33. package/dist/test.js.map +1 -0
  34. package/package.json +12 -2
  35. package/src/client/_generated/_ignore.ts +2 -0
  36. package/src/client/index.test.ts +52 -0
  37. package/src/client/index.ts +784 -0
  38. package/src/client/types.ts +533 -0
  39. package/src/component/_generated/_ignore.ts +2 -0
  40. package/src/component/_generated/api.ts +16 -0
  41. package/src/component/_generated/component.ts +74 -0
  42. package/src/component/_generated/dataModel.ts +40 -0
  43. package/src/component/_generated/server.ts +48 -0
  44. package/src/component/backfillJobs.test.ts +47 -0
  45. package/src/component/backfillJobs.ts +245 -0
  46. package/src/component/connections.test.ts +297 -0
  47. package/src/component/connections.ts +329 -0
  48. package/src/component/convex.config.ts +7 -0
  49. package/src/component/dataPoints.test.ts +282 -0
  50. package/src/component/dataPoints.ts +305 -0
  51. package/src/component/dataSources.test.ts +247 -0
  52. package/src/component/dataSources.ts +109 -0
  53. package/src/component/events.test.ts +380 -0
  54. package/src/component/events.ts +288 -0
  55. package/src/component/garminBackfill.ts +343 -0
  56. package/src/component/garminWebhooks.test.ts +609 -0
  57. package/src/component/garminWebhooks.ts +656 -0
  58. package/src/component/httpHandlers.ts +153 -0
  59. package/src/component/lifecycle.test.ts +179 -0
  60. package/src/component/lifecycle.ts +87 -0
  61. package/src/component/menstrualCycles.ts +124 -0
  62. package/src/component/oauthActions.ts +261 -0
  63. package/src/component/oauthStates.test.ts +170 -0
  64. package/src/component/oauthStates.ts +85 -0
  65. package/src/component/providerSettings.ts +66 -0
  66. package/src/component/providers/additionalProviders.test.ts +401 -0
  67. package/src/component/providers/garmin.ts +1169 -0
  68. package/src/component/providers/oauth.test.ts +174 -0
  69. package/src/component/providers/oauth.ts +246 -0
  70. package/src/component/providers/polar.ts +220 -0
  71. package/src/component/providers/registry.ts +37 -0
  72. package/src/component/providers/strava.test.ts +195 -0
  73. package/src/component/providers/strava.ts +253 -0
  74. package/src/component/providers/suunto.ts +592 -0
  75. package/src/component/providers/types.ts +189 -0
  76. package/src/component/providers/whoop.ts +600 -0
  77. package/src/component/schema.ts +339 -0
  78. package/src/component/sdkPush.test.ts +367 -0
  79. package/src/component/sdkPush.ts +440 -0
  80. package/src/component/summaries.test.ts +201 -0
  81. package/src/component/summaries.ts +143 -0
  82. package/src/component/syncJobs.test.ts +254 -0
  83. package/src/component/syncJobs.ts +140 -0
  84. package/src/component/syncWorkflow.test.ts +87 -0
  85. package/src/component/syncWorkflow.ts +739 -0
  86. package/src/component/test.setup.ts +6 -0
  87. package/src/component/workflowManager.ts +19 -0
  88. package/src/test.ts +25 -0
@@ -0,0 +1,784 @@
1
+ /**
2
+ * @clipin/convex-wearables
3
+ *
4
+ * Convex component for wearable device integrations.
5
+ * Provides health data sync from Garmin, Strava, Whoop, Polar, Suunto,
6
+ * Apple HealthKit, Samsung Health, and Google Health Connect.
7
+ */
8
+
9
+ import type {
10
+ GenericActionCtx,
11
+ GenericDataModel,
12
+ GenericMutationCtx,
13
+ GenericQueryCtx,
14
+ HttpRouter,
15
+ } from "convex/server";
16
+ import { httpActionGeneric } from "convex/server";
17
+ import type { ComponentApi } from "../component/_generated/component.js";
18
+ import type {
19
+ AggregateStats,
20
+ BackfillJob,
21
+ Connection,
22
+ DailySummary,
23
+ DataPoint,
24
+ EventCategory,
25
+ EventsPage,
26
+ GarminRoutesConfig,
27
+ HealthEvent,
28
+ ProviderCredentials,
29
+ ProviderName,
30
+ RegisterRoutesConfig,
31
+ SdkPushDataPoint,
32
+ SdkPushEvent,
33
+ SdkPushPayload,
34
+ SdkPushSummary,
35
+ SdkRoutesConfig,
36
+ SdkSyncPayload,
37
+ SyncJob,
38
+ SyncStatus,
39
+ TimeSeriesPage,
40
+ WearablesConfig,
41
+ } from "./types.js";
42
+
43
+ export {
44
+ oauthCallback,
45
+ stravaWebhookEvent,
46
+ stravaWebhookVerify,
47
+ } from "../component/httpHandlers.js";
48
+ export type { SeriesType, SleepEvent, SleepStage, WorkoutEvent } from "./types.js";
49
+ export { SERIES_TYPES } from "./types.js";
50
+ // Re-export types for consumers
51
+ export type {
52
+ AggregateStats,
53
+ BackfillJob,
54
+ Connection,
55
+ DailySummary,
56
+ DataPoint,
57
+ EventCategory,
58
+ EventsPage,
59
+ GarminRoutesConfig,
60
+ HealthEvent,
61
+ ProviderCredentials,
62
+ ProviderName,
63
+ RegisterRoutesConfig,
64
+ SdkPushDataPoint,
65
+ SdkPushEvent,
66
+ SdkPushPayload,
67
+ SdkPushSummary,
68
+ SdkRoutesConfig,
69
+ SdkSyncPayload,
70
+ SyncJob,
71
+ SyncStatus,
72
+ TimeSeriesPage,
73
+ WearablesConfig,
74
+ };
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Component type — represents the installed component reference
78
+ // ---------------------------------------------------------------------------
79
+
80
+ export type WearablesComponent = ComponentApi;
81
+ type QueryRunner =
82
+ | Pick<GenericQueryCtx<GenericDataModel>, "runQuery">
83
+ | Pick<GenericActionCtx<GenericDataModel>, "runQuery">;
84
+ type MutationRunner =
85
+ | Pick<GenericMutationCtx<GenericDataModel>, "runMutation">
86
+ | Pick<GenericActionCtx<GenericDataModel>, "runMutation">;
87
+ type ActionRunner = Pick<GenericActionCtx<GenericDataModel>, "runAction">;
88
+
89
+ const GARMIN_PUSH_COMPONENT_FUNCTION = "wearables.garminWebhooks.processPushPayload";
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // WearablesClient — the main API surface for host apps
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /**
96
+ * Client for interacting with the @clipin/convex-wearables component.
97
+ *
98
+ * @example
99
+ * ```ts
100
+ * import { WearablesClient } from "@clipin/convex-wearables";
101
+ * import { components } from "./_generated/api";
102
+ *
103
+ * const wearables = new WearablesClient(components.wearables, {
104
+ * providers: {
105
+ * strava: { clientId: "...", clientSecret: "..." },
106
+ * garmin: { clientId: "...", clientSecret: "..." },
107
+ * },
108
+ * });
109
+ *
110
+ * // In a query:
111
+ * export const getWorkouts = query({
112
+ * args: { userId: v.string() },
113
+ * handler: async (ctx, args) => {
114
+ * return await wearables.getEvents(ctx, {
115
+ * userId: args.userId,
116
+ * category: "workout",
117
+ * });
118
+ * },
119
+ * });
120
+ * ```
121
+ */
122
+ export class WearablesClient {
123
+ public component: WearablesComponent;
124
+ public config: WearablesConfig;
125
+
126
+ constructor(component: WearablesComponent, config: WearablesConfig) {
127
+ this.component = component;
128
+ this.config = config;
129
+ }
130
+
131
+ // -----------------------------------------------------------------------
132
+ // Connection Management
133
+ // -----------------------------------------------------------------------
134
+
135
+ /**
136
+ * Get all connections for a user.
137
+ */
138
+ async getConnections(ctx: QueryRunner, args: { userId: string }): Promise<Connection[]> {
139
+ return await ctx.runQuery(this.component.connections.getConnections, {
140
+ userId: args.userId,
141
+ });
142
+ }
143
+
144
+ /**
145
+ * Get connection for a specific user + provider.
146
+ */
147
+ async getConnection(
148
+ ctx: QueryRunner,
149
+ args: { userId: string; provider: ProviderName },
150
+ ): Promise<Connection | null> {
151
+ return await ctx.runQuery(this.component.connections.getByUserProvider, args);
152
+ }
153
+
154
+ /**
155
+ * Get sync status for a user across all providers.
156
+ */
157
+ async getSyncStatus(ctx: QueryRunner, args: { userId: string }): Promise<SyncStatus[]> {
158
+ return await ctx.runQuery(this.component.connections.getSyncStatus, {
159
+ userId: args.userId,
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Disconnect a provider for a user.
165
+ */
166
+ async disconnect(ctx: MutationRunner, args: { userId: string; provider: ProviderName }) {
167
+ return await ctx.runMutation(this.component.connections.disconnect, args);
168
+ }
169
+
170
+ // -----------------------------------------------------------------------
171
+ // Data Access — Events (Workouts, Sleep)
172
+ // -----------------------------------------------------------------------
173
+
174
+ /**
175
+ * Get events (workouts or sleep) for a user with pagination.
176
+ */
177
+ async getEvents(
178
+ ctx: QueryRunner,
179
+ args: {
180
+ userId: string;
181
+ category: EventCategory;
182
+ startDate?: number;
183
+ endDate?: number;
184
+ limit?: number;
185
+ cursor?: string;
186
+ },
187
+ ): Promise<EventsPage> {
188
+ return await ctx.runQuery(this.component.events.getEvents, args);
189
+ }
190
+
191
+ /**
192
+ * Get a single event by ID.
193
+ */
194
+ async getEvent(ctx: QueryRunner, args: { eventId: string }): Promise<HealthEvent | null> {
195
+ return await ctx.runQuery(this.component.events.getEvent, {
196
+ eventId: args.eventId,
197
+ });
198
+ }
199
+
200
+ // -----------------------------------------------------------------------
201
+ // Data Access — Time Series
202
+ // -----------------------------------------------------------------------
203
+
204
+ /**
205
+ * Get time-series data for a user.
206
+ */
207
+ async getTimeSeries(
208
+ ctx: QueryRunner,
209
+ args: {
210
+ userId: string;
211
+ seriesType: string;
212
+ startDate: number;
213
+ endDate: number;
214
+ limit?: number;
215
+ },
216
+ ): Promise<DataPoint[]> {
217
+ return await ctx.runQuery(this.component.dataPoints.getTimeSeriesForUser, args);
218
+ }
219
+
220
+ /**
221
+ * Get the latest data point for a metric.
222
+ */
223
+ async getLatestDataPoint(
224
+ ctx: QueryRunner,
225
+ args: { userId: string; seriesType: string },
226
+ ): Promise<{ timestamp: number; value: number; provider: string } | null> {
227
+ return await ctx.runQuery(this.component.dataPoints.getLatestDataPoint, args);
228
+ }
229
+
230
+ /**
231
+ * Get all available series types for a user.
232
+ */
233
+ async getAvailableSeriesTypes(ctx: QueryRunner, args: { userId: string }): Promise<string[]> {
234
+ return await ctx.runQuery(this.component.dataPoints.getAvailableSeriesTypes, args);
235
+ }
236
+
237
+ // -----------------------------------------------------------------------
238
+ // Data Access — Summaries
239
+ // -----------------------------------------------------------------------
240
+
241
+ /**
242
+ * Get daily summaries for a user.
243
+ */
244
+ async getDailySummaries(
245
+ ctx: QueryRunner,
246
+ args: {
247
+ userId: string;
248
+ category: string;
249
+ startDate: string;
250
+ endDate: string;
251
+ },
252
+ ): Promise<DailySummary[]> {
253
+ return await ctx.runQuery(this.component.summaries.getDailySummaries, args);
254
+ }
255
+
256
+ // -----------------------------------------------------------------------
257
+ // Data Sources
258
+ // -----------------------------------------------------------------------
259
+
260
+ /**
261
+ * Get or create a data source for a user/provider/device.
262
+ */
263
+ async getOrCreateDataSource(
264
+ ctx: MutationRunner,
265
+ args: {
266
+ userId: string;
267
+ provider: ProviderName;
268
+ connectionId?: string;
269
+ deviceModel?: string;
270
+ source?: string;
271
+ deviceType?: string;
272
+ },
273
+ ): Promise<string> {
274
+ return await ctx.runMutation(this.component.dataSources.getOrCreate, args);
275
+ }
276
+
277
+ // -----------------------------------------------------------------------
278
+ // Sync Control
279
+ // -----------------------------------------------------------------------
280
+
281
+ /**
282
+ * Get sync jobs for a user.
283
+ */
284
+ async getSyncJobs(
285
+ ctx: QueryRunner,
286
+ args: { userId: string; limit?: number },
287
+ ): Promise<SyncJob[]> {
288
+ return await ctx.runQuery(this.component.syncJobs.getByUser, args);
289
+ }
290
+
291
+ /**
292
+ * Generate an OAuth authorization URL for a provider using configured credentials.
293
+ */
294
+ async generateAuthUrl(
295
+ ctx: ActionRunner,
296
+ args: { userId: string; provider: ProviderName; redirectUri: string },
297
+ ): Promise<string> {
298
+ const credentials = this.requireProviderCredentials(args.provider);
299
+
300
+ return await ctx.runAction(this.component.oauthActions.generateAuthUrl, {
301
+ userId: args.userId,
302
+ provider: args.provider,
303
+ redirectUri: args.redirectUri,
304
+ clientId: credentials.clientId,
305
+ clientSecret: credentials.clientSecret,
306
+ subscriptionKey: credentials.subscriptionKey,
307
+ });
308
+ }
309
+
310
+ /**
311
+ * Handle an OAuth callback using configured provider credentials.
312
+ */
313
+ async handleCallback(
314
+ ctx: ActionRunner,
315
+ args: { provider: ProviderName; state: string; code: string },
316
+ ): Promise<{ provider: string; userId: string; connectionId: string }> {
317
+ const credentials = this.requireProviderCredentials(args.provider);
318
+
319
+ return await ctx.runAction(this.component.oauthActions.handleCallback, {
320
+ state: args.state,
321
+ code: args.code,
322
+ clientId: credentials.clientId,
323
+ clientSecret: credentials.clientSecret,
324
+ subscriptionKey: credentials.subscriptionKey,
325
+ });
326
+ }
327
+
328
+ /**
329
+ * Enqueue a durable sync for a specific connection.
330
+ */
331
+ async syncConnection(
332
+ ctx: ActionRunner,
333
+ args: {
334
+ connectionId: string;
335
+ startDate?: number;
336
+ endDate?: number;
337
+ syncWindowHours?: number;
338
+ provider: ProviderName;
339
+ },
340
+ ): Promise<{ syncJobId: string; workflowId: string; deduped: boolean }> {
341
+ const credentials = this.requireProviderCredentials(args.provider);
342
+
343
+ return await ctx.runAction(this.component.syncWorkflow.syncConnection, {
344
+ connectionId: args.connectionId,
345
+ provider: args.provider,
346
+ startDate: args.startDate,
347
+ endDate: args.endDate,
348
+ syncWindowHours: args.syncWindowHours,
349
+ clientId: credentials.clientId,
350
+ clientSecret: credentials.clientSecret,
351
+ subscriptionKey: credentials.subscriptionKey,
352
+ });
353
+ }
354
+
355
+ /**
356
+ * Run a sync across all active connections using the configured provider credentials.
357
+ */
358
+ async syncAllActive(ctx: ActionRunner, args?: { syncWindowHours?: number }) {
359
+ return await ctx.runAction(this.component.syncWorkflow.syncAllActive, {
360
+ clientCredentials: this.config.providers,
361
+ syncWindowHours: args?.syncWindowHours,
362
+ });
363
+ }
364
+
365
+ /**
366
+ * Start a durable Garmin historical backfill workflow.
367
+ */
368
+ async startGarminBackfill(
369
+ ctx: ActionRunner,
370
+ args: { connectionId: string; lookbackDays?: number },
371
+ ): Promise<{ backfillJobId: string; workflowId: string; deduped: boolean }> {
372
+ const credentials = this.requireProviderCredentials("garmin");
373
+ return await ctx.runAction(this.component.garminBackfill.startGarminBackfill, {
374
+ connectionId: args.connectionId,
375
+ lookbackDays: args.lookbackDays,
376
+ clientId: credentials.clientId,
377
+ clientSecret: credentials.clientSecret,
378
+ });
379
+ }
380
+
381
+ /**
382
+ * Get the latest Garmin backfill job for a connection.
383
+ */
384
+ async getGarminBackfillStatus(
385
+ ctx: QueryRunner,
386
+ args: { connectionId: string },
387
+ ): Promise<BackfillJob | null> {
388
+ return await ctx.runQuery(this.component.backfillJobs.getLatestByConnection, {
389
+ connectionId: args.connectionId,
390
+ });
391
+ }
392
+
393
+ /**
394
+ * Resolve the configured SDK sync path, or null if the route is disabled.
395
+ */
396
+ getSdkSyncPath(config?: RegisterRoutesConfig): string | null {
397
+ return getSdkSyncPath(config);
398
+ }
399
+
400
+ /**
401
+ * Resolve the full SDK sync URL for a Convex deployment.
402
+ */
403
+ getSdkSyncUrl(baseUrl: string, config?: RegisterRoutesConfig): string | null {
404
+ return getSdkSyncUrl(baseUrl, config);
405
+ }
406
+
407
+ // -----------------------------------------------------------------------
408
+ // Lifecycle
409
+ // -----------------------------------------------------------------------
410
+
411
+ /**
412
+ * Delete all data for a user (GDPR compliance, account deletion).
413
+ */
414
+ async deleteAllUserData(ctx: MutationRunner, args: { userId: string }) {
415
+ return await ctx.runMutation(this.component.lifecycle.deleteAllUserData, {
416
+ userId: args.userId,
417
+ });
418
+ }
419
+
420
+ // -----------------------------------------------------------------------
421
+ // Provider Configuration
422
+ // -----------------------------------------------------------------------
423
+
424
+ /**
425
+ * Get credentials for a provider.
426
+ */
427
+ getProviderCredentials(provider: ProviderName): ProviderCredentials | undefined {
428
+ return this.config.providers[provider];
429
+ }
430
+
431
+ /**
432
+ * Get list of configured providers.
433
+ */
434
+ getConfiguredProviders(): ProviderName[] {
435
+ return Object.keys(this.config.providers) as ProviderName[];
436
+ }
437
+
438
+ private requireProviderCredentials(provider: ProviderName): ProviderCredentials {
439
+ const credentials = this.getProviderCredentials(provider);
440
+ if (!credentials) {
441
+ throw new Error(`Missing credentials for provider "${provider}"`);
442
+ }
443
+ return credentials;
444
+ }
445
+ }
446
+
447
+ /**
448
+ * Register HTTP routes for wearable provider integrations.
449
+ *
450
+ * Registers Garmin webhook routes and the optional normalized SDK push route
451
+ * for Apple Health / Google Health Connect / Samsung Health.
452
+ */
453
+ export function registerRoutes(
454
+ http: HttpRouter,
455
+ component: WearablesComponent,
456
+ config?: RegisterRoutesConfig,
457
+ ) {
458
+ const garminConfig = config?.garmin;
459
+ const sdkConfig = config?.sdk;
460
+ const registerGarminRoutes = garminConfig !== false;
461
+ const registerSdkRoutes = sdkConfig !== undefined && sdkConfig !== false;
462
+
463
+ if (registerGarminRoutes) {
464
+ const webhookPath = garminConfig?.webhookPath ?? "/webhooks/garmin/push";
465
+ const healthPath = garminConfig?.healthPath ?? "/webhooks/garmin/health";
466
+ const oauthCallbackPath = garminConfig?.oauthCallbackPath ?? "/oauth/garmin/callback";
467
+ const expectedClientId = garminConfig?.clientId ?? process.env.GARMIN_CLIENT_ID;
468
+ const clientSecret = garminConfig?.clientSecret ?? process.env.GARMIN_CLIENT_SECRET;
469
+ const successRedirectUrl =
470
+ garminConfig?.successRedirectUrl ??
471
+ process.env.NEXT_PUBLIC_APP_URL ??
472
+ "http://localhost:3000";
473
+ const successQueryParam = garminConfig?.successQueryParam ?? "connected";
474
+
475
+ http.route({
476
+ path: webhookPath,
477
+ method: "POST",
478
+ handler: httpActionGeneric(async (ctx, request) => {
479
+ const garminClientId = request.headers.get("garmin-client-id");
480
+
481
+ if (expectedClientId && garminClientId !== expectedClientId) {
482
+ console.warn("Garmin webhook rejected invalid client ID", {
483
+ receivedClientId: garminClientId,
484
+ expectedClientIdConfigured: Boolean(expectedClientId),
485
+ });
486
+ return new Response("Unauthorized", { status: 401 });
487
+ }
488
+
489
+ let payload: unknown;
490
+ try {
491
+ payload = await request.json();
492
+ } catch (error) {
493
+ console.error("Garmin webhook received invalid JSON", {
494
+ error: serializeError(error),
495
+ });
496
+ return new Response("Bad request", { status: 400 });
497
+ }
498
+
499
+ const payloadSummary = summarizeGarminPayload(payload);
500
+
501
+ console.info("Garmin webhook received payload", {
502
+ componentFunction: GARMIN_PUSH_COMPONENT_FUNCTION,
503
+ garminClientIdPresent: garminClientId !== null,
504
+ expectedClientIdConfigured: Boolean(expectedClientId),
505
+ payloadSummary,
506
+ });
507
+
508
+ try {
509
+ await ctx.runAction(component.garminWebhooks.processPushPayload, {
510
+ payloadJson: JSON.stringify(payload),
511
+ garminClientId: garminClientId ?? "",
512
+ });
513
+
514
+ console.info("Garmin webhook processed successfully", {
515
+ componentFunction: GARMIN_PUSH_COMPONENT_FUNCTION,
516
+ payloadSummary,
517
+ });
518
+
519
+ return new Response("OK", { status: 200 });
520
+ } catch (error) {
521
+ const serializedError = serializeError(error);
522
+ const isUnresolvedComponentFunction =
523
+ serializedError.message.includes("Couldn't resolve") &&
524
+ serializedError.message.includes(GARMIN_PUSH_COMPONENT_FUNCTION);
525
+
526
+ console.error("Garmin webhook processing failed", {
527
+ componentFunction: GARMIN_PUSH_COMPONENT_FUNCTION,
528
+ payloadSummary,
529
+ error: serializedError,
530
+ diagnosis: isUnresolvedComponentFunction
531
+ ? "Convex could not resolve the component action. This usually means the host app is running a stale uploaded component snapshot or stale generated bindings. Rebuild the component package, refresh the local dependency, and restart npx convex dev."
532
+ : undefined,
533
+ });
534
+ return new Response("Internal error", { status: 500 });
535
+ }
536
+ }),
537
+ });
538
+
539
+ if (healthPath !== false) {
540
+ http.route({
541
+ path: healthPath,
542
+ method: "GET",
543
+ handler: httpActionGeneric(async () => {
544
+ return new Response(JSON.stringify({ status: "ok", service: "garmin-webhooks" }), {
545
+ status: 200,
546
+ headers: { "Content-Type": "application/json" },
547
+ });
548
+ }),
549
+ });
550
+ }
551
+
552
+ if (oauthCallbackPath !== false) {
553
+ http.route({
554
+ path: oauthCallbackPath,
555
+ method: "GET",
556
+ handler: httpActionGeneric(async (ctx, request) => {
557
+ const url = new URL(request.url);
558
+ const code = url.searchParams.get("code");
559
+ const state = url.searchParams.get("state");
560
+ const error = url.searchParams.get("error");
561
+
562
+ if (error) {
563
+ return new Response(errorPage(`OAuth error: ${error}`), {
564
+ status: 400,
565
+ headers: { "Content-Type": "text/html" },
566
+ });
567
+ }
568
+
569
+ if (!code || !state) {
570
+ return new Response(errorPage("Missing code or state parameter"), {
571
+ status: 400,
572
+ headers: { "Content-Type": "text/html" },
573
+ });
574
+ }
575
+
576
+ if (!expectedClientId || !clientSecret) {
577
+ return new Response(
578
+ errorPage("GARMIN_CLIENT_ID and GARMIN_CLIENT_SECRET must be set"),
579
+ {
580
+ status: 500,
581
+ headers: { "Content-Type": "text/html" },
582
+ },
583
+ );
584
+ }
585
+
586
+ try {
587
+ const result = await ctx.runAction(component.oauthActions.handleCallback, {
588
+ state,
589
+ code,
590
+ clientId: expectedClientId,
591
+ clientSecret,
592
+ });
593
+
594
+ const redirectUrl = new URL(successRedirectUrl);
595
+ redirectUrl.searchParams.set(successQueryParam, result.provider);
596
+
597
+ return new Response(null, {
598
+ status: 302,
599
+ headers: {
600
+ Location: redirectUrl.toString(),
601
+ },
602
+ });
603
+ } catch (callbackError) {
604
+ const message =
605
+ callbackError instanceof Error ? callbackError.message : "Unknown error";
606
+ return new Response(errorPage(`OAuth callback failed: ${message}`), {
607
+ status: 500,
608
+ headers: { "Content-Type": "text/html" },
609
+ });
610
+ }
611
+ }),
612
+ });
613
+ }
614
+ }
615
+
616
+ if (registerSdkRoutes) {
617
+ const syncPath = sdkConfig?.syncPath ?? "/sdk/sync";
618
+ const expectedToken = sdkConfig?.authToken ?? process.env.WEARABLES_SDK_AUTH_TOKEN;
619
+
620
+ if (syncPath !== false) {
621
+ http.route({
622
+ path: syncPath,
623
+ method: "POST",
624
+ handler: httpActionGeneric(async (ctx, request) => {
625
+ if (expectedToken) {
626
+ const providedToken =
627
+ extractBearerToken(request.headers.get("authorization")) ??
628
+ request.headers.get("x-wearables-sdk-token");
629
+
630
+ if (providedToken !== expectedToken) {
631
+ return new Response("Unauthorized", { status: 401 });
632
+ }
633
+ }
634
+
635
+ let payload: unknown;
636
+ try {
637
+ payload = await request.json();
638
+ } catch {
639
+ return new Response("Bad request", { status: 400 });
640
+ }
641
+
642
+ try {
643
+ const result = await ctx.runAction(
644
+ component.sdkPush.ingestNormalizedPayload,
645
+ payload as SdkPushPayload,
646
+ );
647
+
648
+ return new Response(JSON.stringify(result), {
649
+ status: 200,
650
+ headers: { "Content-Type": "application/json" },
651
+ });
652
+ } catch (error) {
653
+ console.error("SDK sync processing failed", {
654
+ error: serializeError(error),
655
+ });
656
+ return new Response("Internal error", { status: 500 });
657
+ }
658
+ }),
659
+ });
660
+ }
661
+ }
662
+ }
663
+
664
+ /**
665
+ * Resolve the configured SDK sync path, or null if the route is not registered.
666
+ *
667
+ * Pass the same `RegisterRoutesConfig` you use with `registerRoutes()`.
668
+ */
669
+ export function getSdkSyncPath(config?: RegisterRoutesConfig): string | null {
670
+ const sdkConfig = config?.sdk;
671
+ if (sdkConfig === undefined || sdkConfig === false) {
672
+ return null;
673
+ }
674
+ if (sdkConfig.syncPath === false) {
675
+ return null;
676
+ }
677
+ return sdkConfig.syncPath ?? "/sdk/sync";
678
+ }
679
+
680
+ /**
681
+ * Resolve the full SDK sync URL for a Convex deployment, or null if disabled.
682
+ */
683
+ export function getSdkSyncUrl(baseUrl: string, config?: RegisterRoutesConfig): string | null {
684
+ const path = getSdkSyncPath(config);
685
+ if (!path) {
686
+ return null;
687
+ }
688
+ return new URL(path, baseUrl).toString();
689
+ }
690
+
691
+ function summarizeGarminPayload(payload: unknown) {
692
+ if (!isRecord(payload)) {
693
+ return { kind: typeof payload };
694
+ }
695
+
696
+ return {
697
+ kind: "object",
698
+ keys: Object.keys(payload).sort(),
699
+ activities: getArrayLength(payload.activities),
700
+ activityDetails: getArrayLength(payload.activityDetails),
701
+ sleeps: getArrayLength(payload.sleeps),
702
+ dailies: getArrayLength(payload.dailies),
703
+ epochs: getArrayLength(payload.epochs),
704
+ bodyComps: getArrayLength(payload.bodyComps),
705
+ hrv: getArrayLength(payload.hrv),
706
+ stressDetails: getArrayLength(payload.stressDetails),
707
+ respiration: getArrayLength(payload.respiration),
708
+ pulseOx: getArrayLength(payload.pulseOx),
709
+ bloodPressures: getArrayLength(payload.bloodPressures),
710
+ userMetrics: getArrayLength(payload.userMetrics),
711
+ skinTemp: getArrayLength(payload.skinTemp),
712
+ healthSnapshot: getArrayLength(payload.healthSnapshot),
713
+ moveiq: getArrayLength(payload.moveiq),
714
+ menstrualCycleTracking: getArrayLength(payload.menstrualCycleTracking),
715
+ mct: getArrayLength(payload.mct),
716
+ userPermissionsChange: getArrayLength(payload.userPermissionsChange),
717
+ deregistrations: getArrayLength(payload.deregistrations),
718
+ };
719
+ }
720
+
721
+ function serializeError(error: unknown): {
722
+ cause?: unknown;
723
+ message: string;
724
+ name?: string;
725
+ stack?: string[];
726
+ } {
727
+ if (error instanceof Error) {
728
+ return {
729
+ name: error.name,
730
+ message: error.message,
731
+ stack: error.stack?.split("\n").slice(0, 6),
732
+ cause: error.cause ? serializeError(error.cause) : undefined,
733
+ };
734
+ }
735
+
736
+ return {
737
+ message:
738
+ typeof error === "string"
739
+ ? error
740
+ : (() => {
741
+ try {
742
+ return JSON.stringify(error);
743
+ } catch {
744
+ return String(error);
745
+ }
746
+ })(),
747
+ };
748
+ }
749
+
750
+ function extractBearerToken(authHeader: string | null): string | null {
751
+ if (!authHeader) return null;
752
+ const [scheme, token] = authHeader.split(" ", 2);
753
+ if (!scheme || !token) return null;
754
+ if (scheme.toLowerCase() !== "bearer") return null;
755
+ return token;
756
+ }
757
+
758
+ function getArrayLength(value: unknown): number {
759
+ return Array.isArray(value) ? value.length : 0;
760
+ }
761
+
762
+ function isRecord(value: unknown): value is Record<string, unknown> {
763
+ return typeof value === "object" && value !== null && !Array.isArray(value);
764
+ }
765
+
766
+ function escapeHtml(str: string): string {
767
+ return str
768
+ .replace(/&/g, "&amp;")
769
+ .replace(/</g, "&lt;")
770
+ .replace(/>/g, "&gt;")
771
+ .replace(/"/g, "&quot;");
772
+ }
773
+
774
+ function errorPage(message: string): string {
775
+ const safe = escapeHtml(message);
776
+ return `<!DOCTYPE html>
777
+ <html><head><title>Error</title></head>
778
+ <body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#111;color:#eee">
779
+ <div style="text-align:center;max-width:400px">
780
+ <h1 style="font-size:1.25rem">Connection Failed</h1>
781
+ <p style="color:#999">${safe}</p>
782
+ <a href="/" style="color:#60a5fa;text-decoration:none">Back to app</a>
783
+ </div></body></html>`;
784
+ }