@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 CHANGED
@@ -6,7 +6,7 @@
6
6
  "bugs": {
7
7
  "url": "https://github.com/NativeSquare/soma/issues"
8
8
  },
9
- "version": "0.5.0",
9
+ "version": "0.6.0",
10
10
  "license": "Apache-2.0",
11
11
  "keywords": [
12
12
  "convex",
@@ -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 `GARMIN_CONSUMER_KEY` and `GARMIN_CONSUMER_SECRET` from
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 Consumer Key. */
43
- consumerKey: string;
44
- /** Your Garmin application's Consumer Secret. */
45
- consumerSecret: string;
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 consumerKey = process.env.GARMIN_CONSUMER_KEY;
129
- const consumerSecret = process.env.GARMIN_CONSUMER_SECRET;
130
- if (!consumerKey || !consumerSecret) return undefined;
131
- return { consumerKey, consumerSecret };
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 GARMIN_CONSUMER_KEY and " +
141
- "GARMIN_CONSUMER_SECRET environment variables in the Convex dashboard, " +
142
- "or pass { garmin: { consumerKey, consumerSecret } } to the Soma constructor.",
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 1.0a, token storage, and data syncing
874
- // for Garmin. Requires Garmin credentials to be configured either via
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
- * Step 1 of the Garmin OAuth 1.0a flow: obtain a request token.
878
+ * Generate a Garmin OAuth 2.0 authorization URL with PKCE.
879
879
  *
880
- * Returns the temporary `token`, `tokenSecret`, and the `authUrl` to
881
- * redirect the user to.
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 pending OAuth state is stored inside the
884
- * component automatically, and the callback handler registered by
885
- * `registerRoutes` will complete the flow without further host-app
886
- * intervention. This is the recommended approach.
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 `token` and `tokenSecret`
889
- * itself and pass them to `connectGarmin` manually.
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.callbackUrl - The URL Garmin will redirect to after authorization
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 `{ token, tokenSecret, authUrl }`
894
+ * @returns `{ authUrl, state, codeVerifier }`
895
895
  *
896
896
  * @example
897
897
  * ```ts
898
- * // Recommended: pass userId so registerRoutes can handle the callback
899
- * const { authUrl } = await soma.getGarminRequestToken(ctx, {
898
+ * const { authUrl } = await soma.getGarminAuthUrl(ctx, {
900
899
  * userId: "user_123",
901
- * callbackUrl: "https://your-app.convex.site/api/garmin/callback",
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 getGarminRequestToken(
905
+ async getGarminAuthUrl(
907
906
  ctx: ActionCtx,
908
- opts: { callbackUrl?: string; userId?: string },
907
+ opts: { redirectUri?: string; userId?: string },
909
908
  ) {
910
909
  const config = this.requireGarminConfig();
911
- return await ctx.runAction(this.component.garmin.getGarminRequestToken, {
912
- consumerKey: config.consumerKey,
913
- consumerSecret: config.consumerSecret,
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
- * Step 3 of the Garmin OAuth 1.0a flow + initial data sync.
918
+ * Handle the Garmin OAuth 2.0 callback (manual flow).
921
919
  *
922
- * Exchanges the request token + verifier for permanent access tokens,
923
- * creates/reactivates the Soma connection, stores tokens securely,
924
- * and syncs the last 30 days of all data types (activities, dailies,
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
- * `oauth_verifier` query parameter from Garmin.
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.token - The request token from Step 1
933
- * @param args.tokenSecret - The request token secret from Step 1
934
- * @param args.verifier - The oauth_verifier from the callback
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
- * token: v.string(),
943
- * tokenSecret: v.string(),
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
- token: string;
957
- tokenSecret: string;
958
- verifier: string;
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
- consumerKey: config.consumerKey,
965
- consumerSecret: config.consumerSecret,
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 `getGarminRequestToken`,
974
- * exchanges for permanent tokens, creates the connection, and syncs data.
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.oauthToken - The oauth_token from the callback query params
978
- * @param args.oauthVerifier - The oauth_verifier from the callback query params
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: { oauthToken: string; oauthVerifier: string },
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
- consumerKey: config.consumerKey,
989
- consumerSecret: config.consumerSecret,
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
- consumerKey: config.consumerKey,
1027
- consumerSecret: config.consumerSecret,
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
- * Deletes stored tokens and sets the connection to inactive.
1035
- * Garmin OAuth 1.0a tokens cannot be revoked via API, so cleanup
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 GARMIN_CONSUMER_KEY env var. */
1122
- consumerKey?: string;
1123
- /** Override GARMIN_CONSUMER_SECRET env var. */
1124
- consumerSecret?: string;
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 oauthToken = url.searchParams.get("oauth_token");
1260
- const oauthVerifier = url.searchParams.get("oauth_verifier");
1256
+ const code = url.searchParams.get("code");
1257
+ const state = url.searchParams.get("state");
1261
1258
 
1262
- if (!oauthToken || !oauthVerifier) {
1263
- return new Response("Missing oauth_token or oauth_verifier", {
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 consumerKey =
1269
- garmin.consumerKey ?? process.env.GARMIN_CONSUMER_KEY;
1270
- const consumerSecret =
1271
- garmin.consumerSecret ?? process.env.GARMIN_CONSUMER_SECRET;
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 (!consumerKey || !consumerSecret) {
1277
+ if (!clientId || !clientSecret) {
1274
1278
  return new Response(
1275
- "Garmin credentials not configured. Set GARMIN_CONSUMER_KEY and " +
1276
- "GARMIN_CONSUMER_SECRET environment variables, or pass them to registerRoutes.",
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
- oauthToken,
1284
- oauthVerifier,
1285
- consumerKey,
1286
- consumerSecret,
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
- consumerKey: string;
32
- consumerSecret: string;
33
- oauthToken: string;
34
- oauthVerifier: string;
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
- consumerKey: string;
54
- consumerSecret: string;
55
- token: string;
56
- tokenSecret: string;
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
- getGarminRequestToken: FunctionReference<
81
+ getGarminAuthUrl: FunctionReference<
81
82
  "action",
82
83
  "internal",
83
- {
84
- callbackUrl?: string;
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
- consumerKey: string;
97
- consumerSecret: string;
92
+ clientId: string;
93
+ clientSecret: string;
98
94
  endTimeInSeconds?: number;
99
95
  startTimeInSeconds?: number;
100
96
  userId: string;