@nativesquare/soma 0.12.0 → 0.13.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 (60) hide show
  1. package/dist/client/garmin.d.ts +5 -1
  2. package/dist/client/garmin.d.ts.map +1 -1
  3. package/dist/client/garmin.js +148 -0
  4. package/dist/client/garmin.js.map +1 -1
  5. package/dist/client/index.d.ts +5 -6
  6. package/dist/client/index.d.ts.map +1 -1
  7. package/dist/client/index.js +5 -211
  8. package/dist/client/index.js.map +1 -1
  9. package/dist/client/strava.d.ts +11 -6
  10. package/dist/client/strava.d.ts.map +1 -1
  11. package/dist/client/strava.js +64 -0
  12. package/dist/client/strava.js.map +1 -1
  13. package/dist/client/types.d.ts +93 -20
  14. package/dist/client/types.d.ts.map +1 -1
  15. package/dist/component/_generated/component.d.ts +24 -5
  16. package/dist/component/_generated/component.d.ts.map +1 -1
  17. package/dist/component/garmin/private.d.ts +53 -68
  18. package/dist/component/garmin/private.d.ts.map +1 -1
  19. package/dist/component/garmin/private.js +87 -85
  20. package/dist/component/garmin/private.js.map +1 -1
  21. package/dist/component/garmin/public.d.ts +97 -43
  22. package/dist/component/garmin/public.d.ts.map +1 -1
  23. package/dist/component/garmin/public.js +75 -51
  24. package/dist/component/garmin/public.js.map +1 -1
  25. package/dist/component/garmin/webhooks.d.ts +22 -20
  26. package/dist/component/garmin/webhooks.d.ts.map +1 -1
  27. package/dist/component/garmin/webhooks.js +115 -76
  28. package/dist/component/garmin/webhooks.js.map +1 -1
  29. package/dist/component/public.d.ts +15 -15
  30. package/dist/component/schema.d.ts +25 -25
  31. package/dist/component/strava/public.d.ts +12 -8
  32. package/dist/component/strava/public.d.ts.map +1 -1
  33. package/dist/component/strava/public.js +7 -7
  34. package/dist/component/strava/public.js.map +1 -1
  35. package/dist/component/validators/activity.d.ts +4 -4
  36. package/dist/component/validators/body.d.ts +4 -4
  37. package/dist/component/validators/daily.d.ts +4 -4
  38. package/dist/component/validators/nutrition.d.ts +3 -3
  39. package/dist/component/validators/samples.d.ts +4 -4
  40. package/dist/component/validators/shared.d.ts +13 -4
  41. package/dist/component/validators/shared.d.ts.map +1 -1
  42. package/dist/component/validators/shared.js +7 -0
  43. package/dist/component/validators/shared.js.map +1 -1
  44. package/dist/component/validators/sleep.d.ts +5 -5
  45. package/dist/validators.d.ts +41 -40
  46. package/dist/validators.d.ts.map +1 -1
  47. package/dist/validators.js +1 -0
  48. package/dist/validators.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/client/garmin.ts +692 -487
  51. package/src/client/index.ts +10 -279
  52. package/src/client/strava.ts +199 -108
  53. package/src/client/types.ts +303 -215
  54. package/src/component/_generated/component.ts +19 -19
  55. package/src/component/garmin/private.ts +1872 -1870
  56. package/src/component/garmin/public.ts +104 -80
  57. package/src/component/garmin/webhooks.ts +122 -81
  58. package/src/component/strava/public.ts +393 -393
  59. package/src/component/validators/shared.ts +9 -0
  60. package/src/validators.ts +1 -0
@@ -8,11 +8,11 @@ import type {
8
8
  ListTimeRangeArgs,
9
9
  PaginateTimeRangeArgs,
10
10
  RegisterRoutesOptions,
11
- GarminWebhookEventName,
12
- GarminWebhookEvent,
13
11
  } from "./types.js";
14
12
  export type {
15
13
  ActionCtx,
14
+ SomaError,
15
+ SomaResult,
16
16
  SomaStravaConfig,
17
17
  SomaGarminConfig,
18
18
  IngestArgs,
@@ -22,6 +22,8 @@ export type {
22
22
  StravaOAuthOptions,
23
23
  StravaConnectEvent,
24
24
  GarminConnectEvent,
25
+ GarminWebhookActionArgs,
26
+ GarminWebhookActionResult,
25
27
  GarminWebhookEvent,
26
28
  GarminOAuthOptions,
27
29
  GarminWebhookEventName,
@@ -29,26 +31,17 @@ export type {
29
31
  GarminWebhookOptions,
30
32
  RegisterRoutesOptions,
31
33
  } from "./types.js";
32
- import {
33
- httpActionGeneric,
34
- type FunctionReference,
35
- type HttpRouter,
36
- } from "convex/server";
34
+ import type { HttpRouter } from "convex/server";
37
35
  import { SomaGarmin } from "./garmin.js";
38
36
  import { SomaStrava } from "./strava.js";
39
37
 
40
38
  export { SomaGarmin } from "./garmin.js";
41
39
  export { SomaStrava } from "./strava.js";
40
+ export { STRAVA_CALLBACK_PATH } from "./strava.js";
41
+ export { GARMIN_OAUTH_CALLBACK_PATH, GARMIN_WEBHOOK_BASE_PATH } from "./garmin.js";
42
42
 
43
43
  export type SomaComponent = ComponentApi;
44
44
 
45
- // ─── Default OAuth Callback Paths ────────────────────────────────────────────
46
- // Used by `registerRoutes` as defaults. Override per-provider in the opts.
47
-
48
- export const STRAVA_CALLBACK_PATH = "/api/strava/callback";
49
- export const GARMIN_OAUTH_CALLBACK_PATH = "/api/garmin/callback";
50
- export const GARMIN_WEBHOOK_BASE_PATH = "/api/garmin/webhook";
51
-
52
45
  /**
53
46
  * Client class for the @nativesquare/soma Convex component.
54
47
  *
@@ -851,7 +844,7 @@ export class Soma {
851
844
  * },
852
845
  * // Optional catch-all, runs for every registered event
853
846
  * onEvent: async (ctx, event) => {
854
- * console.log(`Garmin ${event.dataType}: ${event.processed} processed`);
847
+ * console.log(`Garmin ${event.dataType}: ${event.items.length} items`);
855
848
  * },
856
849
  * },
857
850
  * },
@@ -866,272 +859,10 @@ export function registerRoutes(
866
859
  const registerAll = opts === undefined;
867
860
 
868
861
  if (registerAll || opts?.strava) {
869
- const stravaOpts = opts?.strava ?? {};
870
- const oauth = stravaOpts.oauth ?? {};
871
- const path = oauth.path ?? STRAVA_CALLBACK_PATH;
872
-
873
- http.route({
874
- path,
875
- method: "GET",
876
- handler: httpActionGeneric(async (ctx, request) => {
877
- const url = new URL(request.url);
878
- const code = url.searchParams.get("code");
879
- const state = url.searchParams.get("state");
880
-
881
- if (!code) {
882
- return new Response("Missing authorization code", { status: 400 });
883
- }
884
- if (!state) {
885
- return new Response(
886
- "Missing state parameter. Ensure the state was included " +
887
- "when building the Strava auth URL via getStravaAuthUrl.",
888
- { status: 400 },
889
- );
890
- }
891
-
892
- const clientId =
893
- oauth.clientId ?? process.env.STRAVA_CLIENT_ID;
894
- const clientSecret =
895
- oauth.clientSecret ?? process.env.STRAVA_CLIENT_SECRET;
896
-
897
- if (!clientId || !clientSecret) {
898
- return new Response(
899
- "Strava credentials not configured. Set STRAVA_CLIENT_ID and " +
900
- "STRAVA_CLIENT_SECRET environment variables, or pass them to registerRoutes.",
901
- { status: 500 },
902
- );
903
- }
904
-
905
- let result: {
906
- connectionId: string;
907
- userId: string;
908
- };
909
- try {
910
- result = await ctx.runAction(component.strava.public.completeStravaOAuth, {
911
- code,
912
- state,
913
- clientId,
914
- clientSecret,
915
- });
916
- } catch (error) {
917
- const message =
918
- error instanceof Error ? error.message : "Unknown error";
919
- return new Response(`Strava OAuth callback failed: ${message}`, {
920
- status: 500,
921
- });
922
- }
923
-
924
- if (oauth.onComplete) {
925
- try {
926
- await oauth.onComplete(ctx, {
927
- provider: "STRAVA",
928
- userId: result.userId,
929
- connectionId: result.connectionId,
930
- });
931
- } catch (callbackError) {
932
- console.error(
933
- "[soma] strava onComplete callback error:",
934
- callbackError instanceof Error ? callbackError.message : callbackError,
935
- );
936
- }
937
- }
938
-
939
- if (oauth.redirectTo) {
940
- return new Response(null, {
941
- status: 302,
942
- headers: { Location: oauth.redirectTo },
943
- });
944
- }
945
-
946
- return new Response("Successfully connected to Strava!", {
947
- status: 200,
948
- });
949
- }),
950
- });
862
+ SomaStrava.registerRoutes(http, component, opts?.strava);
951
863
  }
952
864
 
953
865
  if (registerAll || opts?.garmin) {
954
- const garminOpts = opts?.garmin ?? {};
955
- const oauth = garminOpts.oauth ?? {};
956
- const path = oauth.path ?? GARMIN_OAUTH_CALLBACK_PATH;
957
-
958
- http.route({
959
- path,
960
- method: "GET",
961
- handler: httpActionGeneric(async (ctx, request) => {
962
- const url = new URL(request.url);
963
- const code = url.searchParams.get("code");
964
- const state = url.searchParams.get("state");
965
-
966
- if (!code) {
967
- return new Response("Missing authorization code", {
968
- status: 400,
969
- });
970
- }
971
- if (!state) {
972
- return new Response(
973
- "Missing state parameter. Ensure the state was included " +
974
- "when building the Garmin auth URL.",
975
- { status: 400 },
976
- );
977
- }
978
-
979
- const clientId =
980
- oauth.clientId ?? process.env.GARMIN_CLIENT_ID;
981
- const clientSecret =
982
- oauth.clientSecret ?? process.env.GARMIN_CLIENT_SECRET;
983
-
984
- if (!clientId || !clientSecret) {
985
- return new Response(
986
- "Garmin credentials not configured. Set GARMIN_CLIENT_ID and " +
987
- "GARMIN_CLIENT_SECRET environment variables, or pass them to registerRoutes.",
988
- { status: 500 },
989
- );
990
- }
991
-
992
- let result: {
993
- connectionId: string;
994
- userId: string;
995
- };
996
- try {
997
- result = await ctx.runAction(component.garmin.public.completeGarminOAuth, {
998
- code,
999
- state,
1000
- clientId,
1001
- clientSecret,
1002
- }) as typeof result;
1003
- } catch (error) {
1004
- const message =
1005
- error instanceof Error ? error.message : "Unknown error";
1006
- return new Response(`Garmin OAuth callback failed: ${message}`, {
1007
- status: 500,
1008
- });
1009
- }
1010
-
1011
- if (oauth.onComplete) {
1012
- try {
1013
- await oauth.onComplete(ctx, {
1014
- provider: "GARMIN",
1015
- userId: result.userId,
1016
- connectionId: result.connectionId,
1017
- });
1018
- } catch (callbackError) {
1019
- console.error(
1020
- "[soma] garmin oauth.onComplete callback error:",
1021
- callbackError instanceof Error ? callbackError.message : callbackError,
1022
- );
1023
- }
1024
- }
1025
-
1026
- if (oauth.redirectTo) {
1027
- return new Response(null, {
1028
- status: 302,
1029
- headers: { Location: oauth.redirectTo },
1030
- });
1031
- }
1032
-
1033
- return new Response("Successfully connected to Garmin!", {
1034
- status: 200,
1035
- });
1036
- }),
1037
- });
1038
-
1039
- // ── Garmin Webhook Routes ──────────────────────────────────
1040
- const webhookCfg = typeof garminOpts.webhook === "object" ? garminOpts.webhook : undefined;
1041
- if (webhookCfg?.events) {
1042
- const webhookBase = webhookCfg.basePath ?? GARMIN_WEBHOOK_BASE_PATH;
1043
-
1044
- const webhookRoutes: Array<{
1045
- suffix: string;
1046
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1047
- action: FunctionReference<"action", "internal", { payload: any }>;
1048
- }> = [
1049
- // ACTIVITY category
1050
- { suffix: "/activities", action: component.garmin.webhooks.handleGarminWebhookActivities },
1051
- { suffix: "/activity-details", action: component.garmin.webhooks.handleGarminWebhookActivityDetails },
1052
- { suffix: "/manually-updated-activities", action: component.garmin.webhooks.handleGarminWebhookManuallyUpdatedActivities },
1053
- { suffix: "/move-iq", action: component.garmin.webhooks.handleGarminWebhookMoveIQ },
1054
- // HEALTH category
1055
- { suffix: "/blood-pressures", action: component.garmin.webhooks.handleGarminWebhookBloodPressures },
1056
- { suffix: "/body-compositions", action: component.garmin.webhooks.handleGarminWebhookBodyCompositions },
1057
- { suffix: "/dailies", action: component.garmin.webhooks.handleGarminWebhookDailies },
1058
- { suffix: "/epochs", action: component.garmin.webhooks.handleGarminWebhookEpochs },
1059
- { suffix: "/health-snapshot", action: component.garmin.webhooks.handleGarminWebhookHealthSnapshot },
1060
- { suffix: "/sleeps", action: component.garmin.webhooks.handleGarminWebhookSleeps },
1061
- { suffix: "/hrv", action: component.garmin.webhooks.handleGarminWebhookHRVSummary },
1062
- { suffix: "/stress", action: component.garmin.webhooks.handleGarminWebhookStress },
1063
- { suffix: "/pulse-ox", action: component.garmin.webhooks.handleGarminWebhookPulseOx },
1064
- { suffix: "/respiration", action: component.garmin.webhooks.handleGarminWebhookRespiration },
1065
- { suffix: "/skin-temp", action: component.garmin.webhooks.handleGarminWebhookSkinTemp },
1066
- { suffix: "/user-metrics", action: component.garmin.webhooks.handleGarminWebhookUserMetrics },
1067
- // WOMEN_HEALTH category
1068
- { suffix: "/menstrual-cycle-tracking", action: component.garmin.webhooks.handleGarminWebhookMenstrualCycleTracking },
1069
- ];
1070
-
1071
- for (const route of webhookRoutes) {
1072
- const dataType = route.suffix.slice(1) as GarminWebhookEventName;
1073
-
1074
- // Only register routes the host app explicitly opted into
1075
- if (!webhookCfg.events?.[dataType]) continue;
1076
-
1077
- http.route({
1078
- path: `${webhookBase}${route.suffix}`,
1079
- method: "POST",
1080
- handler: httpActionGeneric(async (ctx, request) => {
1081
- let payload: unknown;
1082
- try {
1083
- payload = await request.json();
1084
- } catch {
1085
- return new Response("Invalid JSON body", { status: 400 });
1086
- }
1087
-
1088
- let result: { processed: number; errors: Array<{ type: string; id: string; error: string }>; affectedUsers: Array<{ userId: string; connectionId: string }> } | undefined;
1089
- try {
1090
- result = await ctx.runAction(route.action, { payload }) as typeof result;
1091
- } catch (error) {
1092
- // Log but return 200 to prevent Garmin from retrying
1093
- console.error(
1094
- `Garmin webhook error (${route.suffix}):`,
1095
- error instanceof Error ? error.message : error,
1096
- );
1097
- }
1098
-
1099
- if (result) {
1100
- const event: GarminWebhookEvent = {
1101
- dataType,
1102
- processed: result.processed,
1103
- errors: result.errors,
1104
- affectedUsers: result.affectedUsers,
1105
- };
1106
-
1107
- const specificHandler = webhookCfg.events?.[dataType];
1108
- if (typeof specificHandler === "function") {
1109
- try {
1110
- await specificHandler(ctx, event);
1111
- } catch (callbackError) {
1112
- console.error(
1113
- `[soma] garmin webhook events["${dataType}"] callback error:`,
1114
- callbackError instanceof Error ? callbackError.message : callbackError,
1115
- );
1116
- }
1117
- }
1118
-
1119
- if (webhookCfg.onEvent) {
1120
- try {
1121
- await webhookCfg.onEvent(ctx, event);
1122
- } catch (callbackError) {
1123
- console.error(
1124
- `[soma] garmin webhook onEvent callback error:`,
1125
- callbackError instanceof Error ? callbackError.message : callbackError,
1126
- );
1127
- }
1128
- }
1129
- }
1130
-
1131
- return new Response("OK", { status: 200 });
1132
- }),
1133
- });
1134
- }
1135
- }
866
+ SomaGarmin.registerRoutes(http, component, opts?.garmin);
1136
867
  }
1137
868
  }
@@ -1,108 +1,199 @@
1
- import type { SomaComponent } from "./index.js";
2
- import type { ActionCtx, SomaStravaConfig } from "./types.js";
3
-
4
- export class SomaStrava {
5
- constructor(
6
- private component: SomaComponent,
7
- private requireConfig: () => SomaStravaConfig,
8
- ) {}
9
-
10
- /**
11
- * Generate a Strava OAuth authorization URL.
12
- *
13
- * The state parameter is stored inside the component automatically,
14
- * and the callback handler registered by `registerRoutes` will
15
- * complete the flow without further host-app intervention.
16
- *
17
- * @param ctx - Action context from the host app
18
- * @param opts.userId - The host app's user identifier
19
- * @param opts.redirectUri - The URL Strava will redirect to after authorization
20
- * @param opts.scope - Comma-separated Strava OAuth scopes (default: "read,activity:read_all,profile:read_all")
21
- * @returns `{ authUrl, state }`
22
- *
23
- * @example
24
- * ```ts
25
- * const { authUrl } = await soma.strava.getAuthUrl(ctx, {
26
- * userId: "user_123",
27
- * redirectUri: "https://your-app.convex.site/api/strava/callback",
28
- * });
29
- * // Redirect user to authUrl — the callback is handled automatically
30
- * ```
31
- */
32
- async getAuthUrl(
33
- ctx: ActionCtx,
34
- opts: { userId: string; redirectUri: string; scope?: string },
35
- ) {
36
- const config = this.requireConfig();
37
- return await ctx.runAction(this.component.strava.public.getStravaAuthUrl, {
38
- clientId: config.clientId,
39
- redirectUri: opts.redirectUri,
40
- scope: opts.scope,
41
- userId: opts.userId,
42
- });
43
- }
44
-
45
- /**
46
- * Sync activities from Strava for an already-connected user.
47
- *
48
- * Automatically refreshes the access token if expired. Fetches the
49
- * athlete profile and activities, transforms them, and ingests into Soma.
50
- *
51
- * @param ctx - Action context from the host app
52
- * @param args.userId - The host app's user identifier
53
- * @param args.after - Only sync activities after this Unix epoch timestamp (for incremental sync)
54
- * @returns `{ synced, errors }`
55
- *
56
- * @example
57
- * ```ts
58
- * export const syncStrava = action({
59
- * args: { userId: v.string() },
60
- * handler: async (ctx, { userId }) => {
61
- * return await soma.strava.sync(ctx, { userId });
62
- * },
63
- * });
64
- * ```
65
- */
66
- async sync(
67
- ctx: ActionCtx,
68
- args: { userId: string; after?: number },
69
- ) {
70
- const config = this.requireConfig();
71
- return await ctx.runAction(this.component.strava.public.syncStrava, {
72
- ...args,
73
- clientId: config.clientId,
74
- clientSecret: config.clientSecret,
75
- });
76
- }
77
-
78
- /**
79
- * Disconnect a user from Strava.
80
- *
81
- * Revokes the token at Strava (best-effort), deletes stored tokens,
82
- * and sets the connection to inactive.
83
- *
84
- * @param ctx - Action context from the host app
85
- * @param args.userId - The host app's user identifier
86
- *
87
- * @example
88
- * ```ts
89
- * export const disconnectStrava = action({
90
- * args: { userId: v.string() },
91
- * handler: async (ctx, { userId }) => {
92
- * await soma.strava.disconnect(ctx, { userId });
93
- * },
94
- * });
95
- * ```
96
- */
97
- async disconnect(
98
- ctx: ActionCtx,
99
- args: { userId: string },
100
- ) {
101
- const config = this.requireConfig();
102
- return await ctx.runAction(this.component.strava.public.disconnectStrava, {
103
- ...args,
104
- clientId: config.clientId,
105
- clientSecret: config.clientSecret,
106
- });
107
- }
108
- }
1
+ import type { SomaComponent } from "./index.js";
2
+ import type { ActionCtx, SomaStravaConfig, RegisterRoutesOptions } from "./types.js";
3
+ import { httpActionGeneric, type HttpRouter } from "convex/server";
4
+
5
+ export const STRAVA_CALLBACK_PATH = "/api/strava/callback";
6
+
7
+ export class SomaStrava {
8
+ constructor(
9
+ private component: SomaComponent,
10
+ private requireConfig: () => SomaStravaConfig,
11
+ ) {}
12
+
13
+ /**
14
+ * Generate a Strava OAuth authorization URL.
15
+ *
16
+ * The state parameter is stored inside the component automatically,
17
+ * and the callback handler registered by `registerRoutes` will
18
+ * complete the flow without further host-app intervention.
19
+ *
20
+ * @param ctx - Action context from the host app
21
+ * @param opts.userId - The host app's user identifier
22
+ * @param opts.redirectUri - The URL Strava will redirect to after authorization
23
+ * @param opts.scope - Comma-separated Strava OAuth scopes (default: "read,activity:read_all,profile:read_all")
24
+ * @returns `{ authUrl, state }`
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * const { authUrl } = await soma.strava.getAuthUrl(ctx, {
29
+ * userId: "user_123",
30
+ * redirectUri: "https://your-app.convex.site/api/strava/callback",
31
+ * });
32
+ * // Redirect user to authUrl — the callback is handled automatically
33
+ * ```
34
+ */
35
+ async getAuthUrl(
36
+ ctx: ActionCtx,
37
+ opts: { userId: string; redirectUri: string; scope?: string },
38
+ ) {
39
+ const config = this.requireConfig();
40
+ return await ctx.runAction(this.component.strava.public.getStravaAuthUrl, {
41
+ clientId: config.clientId,
42
+ redirectUri: opts.redirectUri,
43
+ scope: opts.scope,
44
+ userId: opts.userId,
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Sync activities from Strava for an already-connected user.
50
+ *
51
+ * Automatically refreshes the access token if expired. Fetches the
52
+ * athlete profile and activities, transforms them, and ingests into Soma.
53
+ *
54
+ * @param ctx - Action context from the host app
55
+ * @param args.userId - The host app's user identifier
56
+ * @param args.after - Only sync activities after this Unix epoch timestamp (for incremental sync)
57
+ * @returns `{ synced, errors }`
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * export const syncStrava = action({
62
+ * args: { userId: v.string() },
63
+ * handler: async (ctx, { userId }) => {
64
+ * return await soma.strava.sync(ctx, { userId });
65
+ * },
66
+ * });
67
+ * ```
68
+ */
69
+ async sync(
70
+ ctx: ActionCtx,
71
+ args: { userId: string; after?: number },
72
+ ) {
73
+ const config = this.requireConfig();
74
+ return await ctx.runAction(this.component.strava.public.syncStrava, {
75
+ ...args,
76
+ clientId: config.clientId,
77
+ clientSecret: config.clientSecret,
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Disconnect a user from Strava.
83
+ *
84
+ * Revokes the token at Strava (best-effort), deletes stored tokens,
85
+ * and sets the connection to inactive.
86
+ *
87
+ * @param ctx - Action context from the host app
88
+ * @param args.userId - The host app's user identifier
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * export const disconnectStrava = action({
93
+ * args: { userId: v.string() },
94
+ * handler: async (ctx, { userId }) => {
95
+ * await soma.strava.disconnect(ctx, { userId });
96
+ * },
97
+ * });
98
+ * ```
99
+ */
100
+ async disconnect(
101
+ ctx: ActionCtx,
102
+ args: { userId: string },
103
+ ) {
104
+ const config = this.requireConfig();
105
+ return await ctx.runAction(this.component.strava.public.disconnectStrava, {
106
+ ...args,
107
+ clientId: config.clientId,
108
+ clientSecret: config.clientSecret,
109
+ });
110
+ }
111
+
112
+ static registerRoutes(
113
+ http: HttpRouter,
114
+ component: SomaComponent,
115
+ opts?: RegisterRoutesOptions["strava"],
116
+ ) {
117
+ const oauth = opts?.oauth ?? {};
118
+ const path = oauth.path ?? STRAVA_CALLBACK_PATH;
119
+
120
+ http.route({
121
+ path,
122
+ method: "GET",
123
+ handler: httpActionGeneric(async (ctx, request) => {
124
+ const url = new URL(request.url);
125
+ const code = url.searchParams.get("code");
126
+ const state = url.searchParams.get("state");
127
+
128
+ if (!code) {
129
+ return new Response("Missing authorization code", { status: 400 });
130
+ }
131
+ if (!state) {
132
+ return new Response(
133
+ "Missing state parameter. Ensure the state was included " +
134
+ "when building the Strava auth URL via getStravaAuthUrl.",
135
+ { status: 400 },
136
+ );
137
+ }
138
+
139
+ const clientId =
140
+ opts?.clientId ?? process.env.STRAVA_CLIENT_ID;
141
+ const clientSecret =
142
+ opts?.clientSecret ?? process.env.STRAVA_CLIENT_SECRET;
143
+
144
+ if (!clientId || !clientSecret) {
145
+ return new Response(
146
+ "Strava credentials not configured. Set STRAVA_CLIENT_ID and " +
147
+ "STRAVA_CLIENT_SECRET environment variables, or pass them to registerRoutes.",
148
+ { status: 500 },
149
+ );
150
+ }
151
+
152
+ let result: {
153
+ connectionId: string;
154
+ userId: string;
155
+ };
156
+ try {
157
+ result = await ctx.runAction(component.strava.public.completeStravaOAuth, {
158
+ code,
159
+ state,
160
+ clientId,
161
+ clientSecret,
162
+ });
163
+ } catch (error) {
164
+ const message =
165
+ error instanceof Error ? error.message : "Unknown error";
166
+ return new Response(`Strava OAuth callback failed: ${message}`, {
167
+ status: 500,
168
+ });
169
+ }
170
+
171
+ if (oauth.onComplete) {
172
+ try {
173
+ await oauth.onComplete(ctx, {
174
+ provider: "STRAVA",
175
+ userId: result.userId,
176
+ connectionId: result.connectionId,
177
+ });
178
+ } catch (callbackError) {
179
+ console.error(
180
+ "[soma] strava onComplete callback error:",
181
+ callbackError instanceof Error ? callbackError.message : callbackError,
182
+ );
183
+ }
184
+ }
185
+
186
+ if (oauth.redirectTo) {
187
+ return new Response(null, {
188
+ status: 302,
189
+ headers: { Location: oauth.redirectTo },
190
+ });
191
+ }
192
+
193
+ return new Response("Successfully connected to Strava!", {
194
+ status: 200,
195
+ });
196
+ }),
197
+ });
198
+ }
199
+ }