@nativesquare/soma 0.5.0 → 0.6.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/package.json +1 -1
- package/src/client/index.ts +89 -85
- package/src/component/_generated/component.ts +15 -19
- package/src/component/garmin.ts +140 -124
- package/src/component/schema.ts +9 -10
- package/src/component/strava.ts +0 -1
- package/src/garmin/auth.test.ts +71 -96
- package/src/garmin/auth.ts +129 -193
- package/src/garmin/client.ts +33 -51
- package/src/garmin/index.ts +13 -14
- package/src/garmin/types.ts +9 -10
package/package.json
CHANGED
package/src/client/index.ts
CHANGED
|
@@ -35,14 +35,14 @@ export interface SomaStravaConfig {
|
|
|
35
35
|
* Configuration for the Garmin integration.
|
|
36
36
|
*
|
|
37
37
|
* If not provided to the constructor, the Soma class will attempt to
|
|
38
|
-
* read `
|
|
38
|
+
* read `GARMIN_CLIENT_ID` and `GARMIN_CLIENT_SECRET` from
|
|
39
39
|
* environment variables automatically.
|
|
40
40
|
*/
|
|
41
41
|
export interface SomaGarminConfig {
|
|
42
|
-
/** Your Garmin application's
|
|
43
|
-
|
|
44
|
-
/** Your Garmin application's
|
|
45
|
-
|
|
42
|
+
/** Your Garmin application's Client ID. */
|
|
43
|
+
clientId: string;
|
|
44
|
+
/** Your Garmin application's Client Secret. */
|
|
45
|
+
clientSecret: string;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/**
|
|
@@ -125,10 +125,10 @@ export class Soma {
|
|
|
125
125
|
* Returns undefined if the required vars are not set.
|
|
126
126
|
*/
|
|
127
127
|
private readGarminEnv(): SomaGarminConfig | undefined {
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
if (!
|
|
131
|
-
return {
|
|
128
|
+
const clientId = process.env.GARMIN_CLIENT_ID;
|
|
129
|
+
const clientSecret = process.env.GARMIN_CLIENT_SECRET;
|
|
130
|
+
if (!clientId || !clientSecret) return undefined;
|
|
131
|
+
return { clientId, clientSecret };
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
/**
|
|
@@ -137,9 +137,9 @@ export class Soma {
|
|
|
137
137
|
private requireGarminConfig(): SomaGarminConfig {
|
|
138
138
|
if (!this.garminConfig) {
|
|
139
139
|
throw new Error(
|
|
140
|
-
"Garmin is not configured. Either set
|
|
141
|
-
"
|
|
142
|
-
"or pass { garmin: {
|
|
140
|
+
"Garmin is not configured. Either set GARMIN_CLIENT_ID and " +
|
|
141
|
+
"GARMIN_CLIENT_SECRET environment variables in the Convex dashboard, " +
|
|
142
|
+
"or pass { garmin: { clientId, clientSecret } } to the Soma constructor.",
|
|
143
143
|
);
|
|
144
144
|
}
|
|
145
145
|
return this.garminConfig;
|
|
@@ -870,68 +870,65 @@ export class Soma {
|
|
|
870
870
|
}
|
|
871
871
|
|
|
872
872
|
// ─── Garmin Integration ──────────────────────────────────────────────────────
|
|
873
|
-
// High-level methods that handle OAuth
|
|
874
|
-
// for Garmin. Requires Garmin credentials to be configured either
|
|
875
|
-
// environment variables or the constructor.
|
|
873
|
+
// High-level methods that handle OAuth 2.0 PKCE, token storage, and data
|
|
874
|
+
// syncing for Garmin. Requires Garmin credentials to be configured either
|
|
875
|
+
// via environment variables or the constructor.
|
|
876
876
|
|
|
877
877
|
/**
|
|
878
|
-
*
|
|
878
|
+
* Generate a Garmin OAuth 2.0 authorization URL with PKCE.
|
|
879
879
|
*
|
|
880
|
-
* Returns the
|
|
881
|
-
*
|
|
880
|
+
* Returns the `authUrl` to redirect the user to, along with the `state`
|
|
881
|
+
* and `codeVerifier` used for the PKCE flow.
|
|
882
882
|
*
|
|
883
|
-
* If `userId` is provided, the
|
|
884
|
-
*
|
|
885
|
-
*
|
|
886
|
-
*
|
|
883
|
+
* If `userId` is provided, the PKCE state is stored inside the component
|
|
884
|
+
* automatically, and the callback handler registered by `registerRoutes`
|
|
885
|
+
* will complete the flow without further host-app intervention. This is
|
|
886
|
+
* the recommended approach.
|
|
887
887
|
*
|
|
888
|
-
* If `userId` is omitted, the host app must store
|
|
889
|
-
* itself and pass
|
|
888
|
+
* If `userId` is omitted, the host app must store the returned
|
|
889
|
+
* `codeVerifier` itself and pass it to `connectGarmin` manually.
|
|
890
890
|
*
|
|
891
891
|
* @param ctx - Action context from the host app
|
|
892
|
-
* @param opts.
|
|
892
|
+
* @param opts.redirectUri - The URL Garmin will redirect to after authorization
|
|
893
893
|
* @param opts.userId - The host app's user identifier (required for `registerRoutes` flow)
|
|
894
|
-
* @returns `{
|
|
894
|
+
* @returns `{ authUrl, state, codeVerifier }`
|
|
895
895
|
*
|
|
896
896
|
* @example
|
|
897
897
|
* ```ts
|
|
898
|
-
*
|
|
899
|
-
* const { authUrl } = await soma.getGarminRequestToken(ctx, {
|
|
898
|
+
* const { authUrl } = await soma.getGarminAuthUrl(ctx, {
|
|
900
899
|
* userId: "user_123",
|
|
901
|
-
*
|
|
900
|
+
* redirectUri: "https://your-app.convex.site/api/garmin/callback",
|
|
902
901
|
* });
|
|
903
902
|
* // Redirect user to authUrl — the callback is handled automatically
|
|
904
903
|
* ```
|
|
905
904
|
*/
|
|
906
|
-
async
|
|
905
|
+
async getGarminAuthUrl(
|
|
907
906
|
ctx: ActionCtx,
|
|
908
|
-
opts: {
|
|
907
|
+
opts: { redirectUri?: string; userId?: string },
|
|
909
908
|
) {
|
|
910
909
|
const config = this.requireGarminConfig();
|
|
911
|
-
return await ctx.runAction(this.component.garmin.
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
callbackUrl: opts.callbackUrl,
|
|
910
|
+
return await ctx.runAction(this.component.garmin.getGarminAuthUrl, {
|
|
911
|
+
clientId: config.clientId,
|
|
912
|
+
redirectUri: opts.redirectUri,
|
|
915
913
|
userId: opts.userId,
|
|
916
914
|
});
|
|
917
915
|
}
|
|
918
916
|
|
|
919
917
|
/**
|
|
920
|
-
*
|
|
918
|
+
* Handle the Garmin OAuth 2.0 callback (manual flow).
|
|
921
919
|
*
|
|
922
|
-
* Exchanges the
|
|
923
|
-
*
|
|
924
|
-
*
|
|
925
|
-
* sleep, body composition, menstruation).
|
|
920
|
+
* Exchanges the authorization code for tokens, creates/reactivates the
|
|
921
|
+
* Soma connection, stores tokens securely, and syncs the last 30 days
|
|
922
|
+
* of all data types.
|
|
926
923
|
*
|
|
927
|
-
* Call this from your OAuth callback endpoint after receiving the
|
|
928
|
-
*
|
|
924
|
+
* Call this from your OAuth callback endpoint after receiving the `code`
|
|
925
|
+
* query parameter from Garmin.
|
|
929
926
|
*
|
|
930
927
|
* @param ctx - Action context from the host app
|
|
931
928
|
* @param args.userId - The host app's user identifier
|
|
932
|
-
* @param args.
|
|
933
|
-
* @param args.
|
|
934
|
-
* @param args.
|
|
929
|
+
* @param args.code - The authorization code from the callback
|
|
930
|
+
* @param args.codeVerifier - The PKCE code verifier from Step 1
|
|
931
|
+
* @param args.redirectUri - The redirect URI used in the authorization request
|
|
935
932
|
* @returns `{ connectionId, synced, errors }`
|
|
936
933
|
*
|
|
937
934
|
* @example
|
|
@@ -939,9 +936,8 @@ export class Soma {
|
|
|
939
936
|
* export const handleGarminCallback = action({
|
|
940
937
|
* args: {
|
|
941
938
|
* userId: v.string(),
|
|
942
|
-
*
|
|
943
|
-
*
|
|
944
|
-
* verifier: v.string(),
|
|
939
|
+
* code: v.string(),
|
|
940
|
+
* codeVerifier: v.string(),
|
|
945
941
|
* },
|
|
946
942
|
* handler: async (ctx, args) => {
|
|
947
943
|
* return await soma.connectGarmin(ctx, args);
|
|
@@ -953,40 +949,41 @@ export class Soma {
|
|
|
953
949
|
ctx: ActionCtx,
|
|
954
950
|
args: {
|
|
955
951
|
userId: string;
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
952
|
+
code: string;
|
|
953
|
+
codeVerifier: string;
|
|
954
|
+
redirectUri?: string;
|
|
959
955
|
},
|
|
960
956
|
) {
|
|
961
957
|
const config = this.requireGarminConfig();
|
|
962
958
|
return await ctx.runAction(this.component.garmin.connectGarmin, {
|
|
963
959
|
...args,
|
|
964
|
-
|
|
965
|
-
|
|
960
|
+
clientId: config.clientId,
|
|
961
|
+
clientSecret: config.clientSecret,
|
|
966
962
|
});
|
|
967
963
|
}
|
|
968
964
|
|
|
969
965
|
/**
|
|
970
|
-
* Complete a Garmin OAuth flow using stored pending state.
|
|
966
|
+
* Complete a Garmin OAuth 2.0 flow using stored pending state.
|
|
971
967
|
*
|
|
972
968
|
* This is called automatically by the `registerRoutes` callback handler.
|
|
973
|
-
* It looks up the pending OAuth state stored during `
|
|
974
|
-
* exchanges for
|
|
969
|
+
* It looks up the pending OAuth state stored during `getGarminAuthUrl`,
|
|
970
|
+
* exchanges for tokens, creates the connection, and syncs data.
|
|
975
971
|
*
|
|
976
972
|
* @param ctx - Action context from the host app
|
|
977
|
-
* @param args.
|
|
978
|
-
* @param args.
|
|
973
|
+
* @param args.code - The authorization code from the callback query params
|
|
974
|
+
* @param args.state - The state parameter from the callback query params
|
|
975
|
+
* @param args.redirectUri - The redirect URI used in the authorization request
|
|
979
976
|
* @returns `{ connectionId, synced, errors }`
|
|
980
977
|
*/
|
|
981
978
|
async completeGarminOAuth(
|
|
982
979
|
ctx: ActionCtx,
|
|
983
|
-
args: {
|
|
980
|
+
args: { code: string; state: string; redirectUri?: string },
|
|
984
981
|
) {
|
|
985
982
|
const config = this.requireGarminConfig();
|
|
986
983
|
return await ctx.runAction(this.component.garmin.completeGarminOAuth, {
|
|
987
984
|
...args,
|
|
988
|
-
|
|
989
|
-
|
|
985
|
+
clientId: config.clientId,
|
|
986
|
+
clientSecret: config.clientSecret,
|
|
990
987
|
});
|
|
991
988
|
}
|
|
992
989
|
|
|
@@ -995,6 +992,7 @@ export class Soma {
|
|
|
995
992
|
*
|
|
996
993
|
* Fetches activities, dailies, sleep, body composition, and menstruation
|
|
997
994
|
* data for the specified time range (defaults to last 30 days).
|
|
995
|
+
* Automatically refreshes expired tokens.
|
|
998
996
|
*
|
|
999
997
|
* @param ctx - Action context from the host app
|
|
1000
998
|
* @param args.userId - The host app's user identifier
|
|
@@ -1023,17 +1021,16 @@ export class Soma {
|
|
|
1023
1021
|
const config = this.requireGarminConfig();
|
|
1024
1022
|
return await ctx.runAction(this.component.garmin.syncGarmin, {
|
|
1025
1023
|
...args,
|
|
1026
|
-
|
|
1027
|
-
|
|
1024
|
+
clientId: config.clientId,
|
|
1025
|
+
clientSecret: config.clientSecret,
|
|
1028
1026
|
});
|
|
1029
1027
|
}
|
|
1030
1028
|
|
|
1031
1029
|
/**
|
|
1032
1030
|
* Disconnect a user from Garmin.
|
|
1033
1031
|
*
|
|
1034
|
-
*
|
|
1035
|
-
*
|
|
1036
|
-
* is local only.
|
|
1032
|
+
* Deregisters the user at Garmin (best-effort), deletes stored tokens,
|
|
1033
|
+
* and sets the connection to inactive.
|
|
1037
1034
|
*
|
|
1038
1035
|
* @param ctx - Action context from the host app
|
|
1039
1036
|
* @param args.userId - The host app's user identifier
|
|
@@ -1118,10 +1115,10 @@ export interface StravaRouteOptions {
|
|
|
1118
1115
|
export interface GarminRouteOptions {
|
|
1119
1116
|
/** HTTP path for the OAuth callback. @default "/api/garmin/callback" */
|
|
1120
1117
|
path?: string;
|
|
1121
|
-
/** Override
|
|
1122
|
-
|
|
1123
|
-
/** Override
|
|
1124
|
-
|
|
1118
|
+
/** Override GARMIN_CLIENT_ID env var. */
|
|
1119
|
+
clientId?: string;
|
|
1120
|
+
/** Override GARMIN_CLIENT_SECRET env var. */
|
|
1121
|
+
clientSecret?: string;
|
|
1125
1122
|
/** URL to redirect the user to after a successful connection. */
|
|
1126
1123
|
onSuccess?: string;
|
|
1127
1124
|
}
|
|
@@ -1256,34 +1253,41 @@ export function registerRoutes(
|
|
|
1256
1253
|
method: "GET",
|
|
1257
1254
|
handler: httpActionGeneric(async (ctx, request) => {
|
|
1258
1255
|
const url = new URL(request.url);
|
|
1259
|
-
const
|
|
1260
|
-
const
|
|
1256
|
+
const code = url.searchParams.get("code");
|
|
1257
|
+
const state = url.searchParams.get("state");
|
|
1261
1258
|
|
|
1262
|
-
if (!
|
|
1263
|
-
return new Response("Missing
|
|
1259
|
+
if (!code) {
|
|
1260
|
+
return new Response("Missing authorization code", {
|
|
1264
1261
|
status: 400,
|
|
1265
1262
|
});
|
|
1266
1263
|
}
|
|
1264
|
+
if (!state) {
|
|
1265
|
+
return new Response(
|
|
1266
|
+
"Missing state parameter. Ensure the state was included " +
|
|
1267
|
+
"when building the Garmin auth URL.",
|
|
1268
|
+
{ status: 400 },
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1267
1271
|
|
|
1268
|
-
const
|
|
1269
|
-
garmin.
|
|
1270
|
-
const
|
|
1271
|
-
garmin.
|
|
1272
|
+
const clientId =
|
|
1273
|
+
garmin.clientId ?? process.env.GARMIN_CLIENT_ID;
|
|
1274
|
+
const clientSecret =
|
|
1275
|
+
garmin.clientSecret ?? process.env.GARMIN_CLIENT_SECRET;
|
|
1272
1276
|
|
|
1273
|
-
if (!
|
|
1277
|
+
if (!clientId || !clientSecret) {
|
|
1274
1278
|
return new Response(
|
|
1275
|
-
"Garmin credentials not configured. Set
|
|
1276
|
-
"
|
|
1279
|
+
"Garmin credentials not configured. Set GARMIN_CLIENT_ID and " +
|
|
1280
|
+
"GARMIN_CLIENT_SECRET environment variables, or pass them to registerRoutes.",
|
|
1277
1281
|
{ status: 500 },
|
|
1278
1282
|
);
|
|
1279
1283
|
}
|
|
1280
1284
|
|
|
1281
1285
|
try {
|
|
1282
1286
|
await ctx.runAction(component.garmin.completeGarminOAuth, {
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
+
code,
|
|
1288
|
+
state,
|
|
1289
|
+
clientId,
|
|
1290
|
+
clientSecret,
|
|
1287
1291
|
});
|
|
1288
1292
|
} catch (error) {
|
|
1289
1293
|
const message =
|
|
@@ -28,10 +28,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
28
28
|
"action",
|
|
29
29
|
"internal",
|
|
30
30
|
{
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
clientId: string;
|
|
32
|
+
clientSecret: string;
|
|
33
|
+
code: string;
|
|
34
|
+
redirectUri?: string;
|
|
35
|
+
state: string;
|
|
35
36
|
},
|
|
36
37
|
{
|
|
37
38
|
connectionId: string;
|
|
@@ -50,12 +51,12 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
50
51
|
"action",
|
|
51
52
|
"internal",
|
|
52
53
|
{
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
clientId: string;
|
|
55
|
+
clientSecret: string;
|
|
56
|
+
code: string;
|
|
57
|
+
codeVerifier: string;
|
|
58
|
+
redirectUri?: string;
|
|
57
59
|
userId: string;
|
|
58
|
-
verifier: string;
|
|
59
60
|
},
|
|
60
61
|
{
|
|
61
62
|
connectionId: string;
|
|
@@ -77,24 +78,19 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
77
78
|
null,
|
|
78
79
|
Name
|
|
79
80
|
>;
|
|
80
|
-
|
|
81
|
+
getGarminAuthUrl: FunctionReference<
|
|
81
82
|
"action",
|
|
82
83
|
"internal",
|
|
83
|
-
{
|
|
84
|
-
|
|
85
|
-
consumerKey: string;
|
|
86
|
-
consumerSecret: string;
|
|
87
|
-
userId?: string;
|
|
88
|
-
},
|
|
89
|
-
{ authUrl: string; token: string; tokenSecret: string },
|
|
84
|
+
{ clientId: string; redirectUri?: string; userId?: string },
|
|
85
|
+
{ authUrl: string; codeVerifier: string; state: string },
|
|
90
86
|
Name
|
|
91
87
|
>;
|
|
92
88
|
syncGarmin: FunctionReference<
|
|
93
89
|
"action",
|
|
94
90
|
"internal",
|
|
95
91
|
{
|
|
96
|
-
|
|
97
|
-
|
|
92
|
+
clientId: string;
|
|
93
|
+
clientSecret: string;
|
|
98
94
|
endTimeInSeconds?: number;
|
|
99
95
|
startTimeInSeconds?: number;
|
|
100
96
|
userId: string;
|