@nativesquare/soma 0.4.0 → 0.5.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.
@@ -1,9 +1,16 @@
1
1
  import type { ComponentApi } from "../component/_generated/component.js";
2
2
  import type { ActionCtx, MutationCtx, QueryCtx } from "./types.js";
3
+ import { httpActionGeneric, type HttpRouter } from "convex/server";
3
4
  import { buildAuthUrl } from "../strava/auth.js";
4
5
 
5
6
  export type SomaComponent = ComponentApi;
6
7
 
8
+ // ─── Default OAuth Callback Paths ────────────────────────────────────────────
9
+ // Used by `registerRoutes` as defaults. Override per-provider in the opts.
10
+
11
+ export const STRAVA_CALLBACK_PATH = "/api/strava/callback";
12
+ export const GARMIN_CALLBACK_PATH = "/api/garmin/callback";
13
+
7
14
  /**
8
15
  * Configuration for the Strava integration.
9
16
  *
@@ -871,31 +878,41 @@ export class Soma {
871
878
  * Step 1 of the Garmin OAuth 1.0a flow: obtain a request token.
872
879
  *
873
880
  * Returns the temporary `token`, `tokenSecret`, and the `authUrl` to
874
- * redirect the user to. The host app must store `token` and `tokenSecret`
875
- * temporarily (e.g., in session/cookie) and pass them to `connectGarmin`
876
- * after the user authorizes.
881
+ * redirect the user to.
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.
887
+ *
888
+ * If `userId` is omitted, the host app must store `token` and `tokenSecret`
889
+ * itself and pass them to `connectGarmin` manually.
877
890
  *
878
891
  * @param ctx - Action context from the host app
879
892
  * @param opts.callbackUrl - The URL Garmin will redirect to after authorization
893
+ * @param opts.userId - The host app's user identifier (required for `registerRoutes` flow)
880
894
  * @returns `{ token, tokenSecret, authUrl }`
881
895
  *
882
896
  * @example
883
897
  * ```ts
884
- * const { token, tokenSecret, authUrl } = await soma.getGarminRequestToken(ctx, {
885
- * callbackUrl: "https://your-app.com/api/garmin/callback",
898
+ * // Recommended: pass userId so registerRoutes can handle the callback
899
+ * const { authUrl } = await soma.getGarminRequestToken(ctx, {
900
+ * userId: "user_123",
901
+ * callbackUrl: "https://your-app.convex.site/api/garmin/callback",
886
902
  * });
887
- * // Store token + tokenSecret in session, redirect user to authUrl
903
+ * // Redirect user to authUrl the callback is handled automatically
888
904
  * ```
889
905
  */
890
906
  async getGarminRequestToken(
891
907
  ctx: ActionCtx,
892
- opts: { callbackUrl?: string },
908
+ opts: { callbackUrl?: string; userId?: string },
893
909
  ) {
894
910
  const config = this.requireGarminConfig();
895
911
  return await ctx.runAction(this.component.garmin.getGarminRequestToken, {
896
912
  consumerKey: config.consumerKey,
897
913
  consumerSecret: config.consumerSecret,
898
914
  callbackUrl: opts.callbackUrl,
915
+ userId: opts.userId,
899
916
  });
900
917
  }
901
918
 
@@ -949,6 +966,30 @@ export class Soma {
949
966
  });
950
967
  }
951
968
 
969
+ /**
970
+ * Complete a Garmin OAuth flow using stored pending state.
971
+ *
972
+ * 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.
975
+ *
976
+ * @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
979
+ * @returns `{ connectionId, synced, errors }`
980
+ */
981
+ async completeGarminOAuth(
982
+ ctx: ActionCtx,
983
+ args: { oauthToken: string; oauthVerifier: string },
984
+ ) {
985
+ const config = this.requireGarminConfig();
986
+ return await ctx.runAction(this.component.garmin.completeGarminOAuth, {
987
+ ...args,
988
+ consumerKey: config.consumerKey,
989
+ consumerSecret: config.consumerSecret,
990
+ });
991
+ }
992
+
952
993
  /**
953
994
  * Sync all data types from Garmin for an already-connected user.
954
995
  *
@@ -1055,3 +1096,214 @@ type ListTimeRangeArgs = TimeRangeArgs & {
1055
1096
  type PaginateTimeRangeArgs = TimeRangeArgs & {
1056
1097
  paginationOpts: { numItems: number; cursor: string | null };
1057
1098
  };
1099
+
1100
+ // ─── Route Registration ──────────────────────────────────────────────────────
1101
+
1102
+ /**
1103
+ * Per-provider options for `registerRoutes`.
1104
+ */
1105
+ export interface StravaRouteOptions {
1106
+ /** HTTP path for the OAuth callback. @default "/api/strava/callback" */
1107
+ path?: string;
1108
+ /** Override STRAVA_CLIENT_ID env var. */
1109
+ clientId?: string;
1110
+ /** Override STRAVA_CLIENT_SECRET env var. */
1111
+ clientSecret?: string;
1112
+ /** Override STRAVA_BASE_URL env var. */
1113
+ baseUrl?: string;
1114
+ /** URL to redirect the user to after a successful connection. */
1115
+ onSuccess?: string;
1116
+ }
1117
+
1118
+ export interface GarminRouteOptions {
1119
+ /** HTTP path for the OAuth callback. @default "/api/garmin/callback" */
1120
+ path?: string;
1121
+ /** Override GARMIN_CONSUMER_KEY env var. */
1122
+ consumerKey?: string;
1123
+ /** Override GARMIN_CONSUMER_SECRET env var. */
1124
+ consumerSecret?: string;
1125
+ /** URL to redirect the user to after a successful connection. */
1126
+ onSuccess?: string;
1127
+ }
1128
+
1129
+ export interface RegisterRoutesOptions {
1130
+ strava?: StravaRouteOptions;
1131
+ garmin?: GarminRouteOptions;
1132
+ }
1133
+
1134
+ /**
1135
+ * Register OAuth callback HTTP routes for Soma providers.
1136
+ *
1137
+ * Call this from your `convex/http.ts` to set up the callback endpoints
1138
+ * that Strava and Garmin redirect to after user authorization. The handlers
1139
+ * complete the OAuth exchange, create the connection, and sync data
1140
+ * automatically.
1141
+ *
1142
+ * When called with no `opts`, registers both Strava and Garmin routes with
1143
+ * default paths and credentials from environment variables. When `opts` is
1144
+ * provided, only registers routes for the providers specified.
1145
+ *
1146
+ * @param http - The Convex HTTP router from `httpRouter()`
1147
+ * @param component - The Soma component reference (`components.soma`)
1148
+ * @param opts - Optional per-provider configuration
1149
+ *
1150
+ * @example
1151
+ * ```ts
1152
+ * // convex/http.ts
1153
+ * import { httpRouter } from "convex/server";
1154
+ * import { registerRoutes } from "@nativesquare/soma";
1155
+ * import { components } from "./_generated/api";
1156
+ *
1157
+ * const http = httpRouter();
1158
+ * registerRoutes(http, components.soma);
1159
+ * export default http;
1160
+ * ```
1161
+ *
1162
+ * @example
1163
+ * ```ts
1164
+ * // With custom paths and redirect
1165
+ * registerRoutes(http, components.soma, {
1166
+ * strava: {
1167
+ * path: "/oauth/strava/callback",
1168
+ * onSuccess: "https://myapp.com/settings",
1169
+ * },
1170
+ * garmin: {
1171
+ * path: "/oauth/garmin/callback",
1172
+ * onSuccess: "https://myapp.com/settings",
1173
+ * },
1174
+ * });
1175
+ * ```
1176
+ */
1177
+ export function registerRoutes(
1178
+ http: HttpRouter,
1179
+ component: SomaComponent,
1180
+ opts?: RegisterRoutesOptions,
1181
+ ) {
1182
+ const registerAll = opts === undefined;
1183
+
1184
+ if (registerAll || opts?.strava) {
1185
+ const strava = opts?.strava ?? {};
1186
+ const path = strava.path ?? STRAVA_CALLBACK_PATH;
1187
+
1188
+ http.route({
1189
+ path,
1190
+ method: "GET",
1191
+ handler: httpActionGeneric(async (ctx, request) => {
1192
+ const url = new URL(request.url);
1193
+ const code = url.searchParams.get("code");
1194
+ const userId = url.searchParams.get("state");
1195
+
1196
+ if (!code) {
1197
+ return new Response("Missing authorization code", { status: 400 });
1198
+ }
1199
+ if (!userId) {
1200
+ return new Response(
1201
+ "Missing state parameter (userId). Pass the userId as the state " +
1202
+ "parameter when building the Strava auth URL.",
1203
+ { status: 400 },
1204
+ );
1205
+ }
1206
+
1207
+ const clientId =
1208
+ strava.clientId ?? process.env.STRAVA_CLIENT_ID;
1209
+ const clientSecret =
1210
+ strava.clientSecret ?? process.env.STRAVA_CLIENT_SECRET;
1211
+
1212
+ if (!clientId || !clientSecret) {
1213
+ return new Response(
1214
+ "Strava credentials not configured. Set STRAVA_CLIENT_ID and " +
1215
+ "STRAVA_CLIENT_SECRET environment variables, or pass them to registerRoutes.",
1216
+ { status: 500 },
1217
+ );
1218
+ }
1219
+
1220
+ try {
1221
+ await ctx.runAction(component.strava.connectStrava, {
1222
+ userId,
1223
+ code,
1224
+ clientId,
1225
+ clientSecret,
1226
+ baseUrl: strava.baseUrl ?? process.env.STRAVA_BASE_URL,
1227
+ });
1228
+ } catch (error) {
1229
+ const message =
1230
+ error instanceof Error ? error.message : "Unknown error";
1231
+ return new Response(`Strava OAuth callback failed: ${message}`, {
1232
+ status: 500,
1233
+ });
1234
+ }
1235
+
1236
+ if (strava.onSuccess) {
1237
+ return new Response(null, {
1238
+ status: 302,
1239
+ headers: { Location: strava.onSuccess },
1240
+ });
1241
+ }
1242
+
1243
+ return new Response("Successfully connected to Strava!", {
1244
+ status: 200,
1245
+ });
1246
+ }),
1247
+ });
1248
+ }
1249
+
1250
+ if (registerAll || opts?.garmin) {
1251
+ const garmin = opts?.garmin ?? {};
1252
+ const path = garmin.path ?? GARMIN_CALLBACK_PATH;
1253
+
1254
+ http.route({
1255
+ path,
1256
+ method: "GET",
1257
+ handler: httpActionGeneric(async (ctx, request) => {
1258
+ const url = new URL(request.url);
1259
+ const oauthToken = url.searchParams.get("oauth_token");
1260
+ const oauthVerifier = url.searchParams.get("oauth_verifier");
1261
+
1262
+ if (!oauthToken || !oauthVerifier) {
1263
+ return new Response("Missing oauth_token or oauth_verifier", {
1264
+ status: 400,
1265
+ });
1266
+ }
1267
+
1268
+ const consumerKey =
1269
+ garmin.consumerKey ?? process.env.GARMIN_CONSUMER_KEY;
1270
+ const consumerSecret =
1271
+ garmin.consumerSecret ?? process.env.GARMIN_CONSUMER_SECRET;
1272
+
1273
+ if (!consumerKey || !consumerSecret) {
1274
+ return new Response(
1275
+ "Garmin credentials not configured. Set GARMIN_CONSUMER_KEY and " +
1276
+ "GARMIN_CONSUMER_SECRET environment variables, or pass them to registerRoutes.",
1277
+ { status: 500 },
1278
+ );
1279
+ }
1280
+
1281
+ try {
1282
+ await ctx.runAction(component.garmin.completeGarminOAuth, {
1283
+ oauthToken,
1284
+ oauthVerifier,
1285
+ consumerKey,
1286
+ consumerSecret,
1287
+ });
1288
+ } catch (error) {
1289
+ const message =
1290
+ error instanceof Error ? error.message : "Unknown error";
1291
+ return new Response(`Garmin OAuth callback failed: ${message}`, {
1292
+ status: 500,
1293
+ });
1294
+ }
1295
+
1296
+ if (garmin.onSuccess) {
1297
+ return new Response(null, {
1298
+ status: 302,
1299
+ headers: { Location: garmin.onSuccess },
1300
+ });
1301
+ }
1302
+
1303
+ return new Response("Successfully connected to Garmin!", {
1304
+ status: 200,
1305
+ });
1306
+ }),
1307
+ });
1308
+ }
1309
+ }
@@ -24,6 +24,28 @@ import type { FunctionReference } from "convex/server";
24
24
  export type ComponentApi<Name extends string | undefined = string | undefined> =
25
25
  {
26
26
  garmin: {
27
+ completeGarminOAuth: FunctionReference<
28
+ "action",
29
+ "internal",
30
+ {
31
+ consumerKey: string;
32
+ consumerSecret: string;
33
+ oauthToken: string;
34
+ oauthVerifier: string;
35
+ },
36
+ {
37
+ connectionId: string;
38
+ errors: Array<{ error: string; id: string; type: string }>;
39
+ synced: {
40
+ activities: number;
41
+ body: number;
42
+ dailies: number;
43
+ menstruation: number;
44
+ sleep: number;
45
+ };
46
+ },
47
+ Name
48
+ >;
27
49
  connectGarmin: FunctionReference<
28
50
  "action",
29
51
  "internal",
@@ -58,7 +80,12 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
58
80
  getGarminRequestToken: FunctionReference<
59
81
  "action",
60
82
  "internal",
61
- { callbackUrl?: string; consumerKey: string; consumerSecret: string },
83
+ {
84
+ callbackUrl?: string;
85
+ consumerKey: string;
86
+ consumerSecret: string;
87
+ userId?: string;
88
+ },
62
89
  { authUrl: string; token: string; tokenSecret: string },
63
90
  Name
64
91
  >;
@@ -29,6 +29,64 @@ const internalApi: any = anyApi;
29
29
  // Default sync window: last 30 days
30
30
  const DEFAULT_SYNC_DAYS = 30;
31
31
 
32
+ // ─── Internal Pending OAuth CRUD ─────────────────────────────────────────────
33
+ // Temporary storage for in-progress Garmin OAuth 1.0a flows.
34
+ // Bridges Step 1 (getGarminRequestToken) and Step 3 (completeGarminOAuth).
35
+
36
+ export const storePendingOAuth = internalMutation({
37
+ args: {
38
+ provider: v.string(),
39
+ oauthToken: v.string(),
40
+ tokenSecret: v.string(),
41
+ userId: v.string(),
42
+ },
43
+ returns: v.null(),
44
+ handler: async (ctx, args) => {
45
+ await ctx.db.insert("pendingOAuth", {
46
+ ...args,
47
+ createdAt: Date.now(),
48
+ });
49
+ return null;
50
+ },
51
+ });
52
+
53
+ export const getPendingOAuth = internalQuery({
54
+ args: { oauthToken: v.string() },
55
+ returns: v.union(
56
+ v.object({
57
+ _id: v.id("pendingOAuth"),
58
+ _creationTime: v.number(),
59
+ provider: v.string(),
60
+ oauthToken: v.string(),
61
+ tokenSecret: v.string(),
62
+ userId: v.string(),
63
+ createdAt: v.number(),
64
+ }),
65
+ v.null(),
66
+ ),
67
+ handler: async (ctx, args) => {
68
+ return await ctx.db
69
+ .query("pendingOAuth")
70
+ .withIndex("by_oauthToken", (q) => q.eq("oauthToken", args.oauthToken))
71
+ .first();
72
+ },
73
+ });
74
+
75
+ export const deletePendingOAuth = internalMutation({
76
+ args: { oauthToken: v.string() },
77
+ returns: v.null(),
78
+ handler: async (ctx, args) => {
79
+ const pending = await ctx.db
80
+ .query("pendingOAuth")
81
+ .withIndex("by_oauthToken", (q) => q.eq("oauthToken", args.oauthToken))
82
+ .first();
83
+ if (pending) {
84
+ await ctx.db.delete(pending._id);
85
+ }
86
+ return null;
87
+ },
88
+ });
89
+
32
90
  // ─── Internal Token CRUD ─────────────────────────────────────────────────────
33
91
 
34
92
  /**
@@ -120,28 +178,42 @@ export const deleteTokens = internalMutation({
120
178
  /**
121
179
  * Step 1 of OAuth: obtain a request token and return the authorization URL.
122
180
  *
123
- * The host app must temporarily store the returned `token` and `tokenSecret`,
124
- * then redirect the user to `authUrl`. After the user authorizes, Garmin
125
- * redirects back with an `oauth_verifier` which is passed to `connectGarmin`.
181
+ * If `userId` is provided, the request token and secret are stored in the
182
+ * component's `pendingOAuth` table so that `completeGarminOAuth` can look
183
+ * them up automatically when the callback fires. This is the recommended
184
+ * flow when using `registerRoutes`.
185
+ *
186
+ * If `userId` is omitted, the host app must store the returned `token`
187
+ * and `tokenSecret` itself and pass them to `connectGarmin` manually.
126
188
  */
127
189
  export const getGarminRequestToken = action({
128
190
  args: {
129
191
  consumerKey: v.string(),
130
192
  consumerSecret: v.string(),
131
193
  callbackUrl: v.optional(v.string()),
194
+ userId: v.optional(v.string()),
132
195
  },
133
196
  returns: v.object({
134
197
  token: v.string(),
135
198
  tokenSecret: v.string(),
136
199
  authUrl: v.string(),
137
200
  }),
138
- handler: async (_ctx, args) => {
201
+ handler: async (ctx, args) => {
139
202
  const result = await getRequestToken({
140
203
  consumerKey: args.consumerKey,
141
204
  consumerSecret: args.consumerSecret,
142
205
  callbackUrl: args.callbackUrl,
143
206
  });
144
207
 
208
+ if (args.userId) {
209
+ await ctx.runMutation(internalApi.garmin.storePendingOAuth, {
210
+ provider: "GARMIN",
211
+ oauthToken: result.oauthToken,
212
+ tokenSecret: result.oauthTokenSecret,
213
+ userId: args.userId,
214
+ });
215
+ }
216
+
145
217
  return {
146
218
  token: result.oauthToken,
147
219
  tokenSecret: result.oauthTokenSecret,
@@ -239,6 +311,111 @@ export const connectGarmin = action({
239
311
  },
240
312
  });
241
313
 
314
+ /**
315
+ * Complete a Garmin OAuth flow using stored pending state.
316
+ *
317
+ * Used by `registerRoutes` — the callback handler calls this with the
318
+ * `oauth_token` and `oauth_verifier` from the redirect. The action looks
319
+ * up the pending state (tokenSecret, userId) stored during Step 1,
320
+ * exchanges for permanent tokens, creates the connection, syncs data,
321
+ * and cleans up the pending entry.
322
+ */
323
+ export const completeGarminOAuth = action({
324
+ args: {
325
+ oauthToken: v.string(),
326
+ oauthVerifier: v.string(),
327
+ consumerKey: v.string(),
328
+ consumerSecret: v.string(),
329
+ },
330
+ returns: v.object({
331
+ connectionId: v.string(),
332
+ synced: v.object({
333
+ activities: v.number(),
334
+ dailies: v.number(),
335
+ sleep: v.number(),
336
+ body: v.number(),
337
+ menstruation: v.number(),
338
+ }),
339
+ errors: v.array(
340
+ v.object({ type: v.string(), id: v.string(), error: v.string() }),
341
+ ),
342
+ }),
343
+ handler: async (ctx, args) => {
344
+ // 1. Look up pending state
345
+ const pending = await ctx.runQuery(internalApi.garmin.getPendingOAuth, {
346
+ oauthToken: args.oauthToken,
347
+ });
348
+ if (!pending) {
349
+ throw new Error(
350
+ "No pending Garmin OAuth state found for this token. " +
351
+ "The request token may have expired or was already used.",
352
+ );
353
+ }
354
+
355
+ // 2. Exchange request token for permanent access token
356
+ const accessTokenResult = await getAccessToken({
357
+ consumerKey: args.consumerKey,
358
+ consumerSecret: args.consumerSecret,
359
+ token: args.oauthToken,
360
+ tokenSecret: pending.tokenSecret,
361
+ verifier: args.oauthVerifier,
362
+ });
363
+
364
+ // 3. Delete pending state (no longer needed)
365
+ await ctx.runMutation(internalApi.garmin.deletePendingOAuth, {
366
+ oauthToken: args.oauthToken,
367
+ });
368
+
369
+ // 4. Create/reactivate the Soma connection
370
+ const connectionId = await ctx.runMutation(publicApi.public.connect, {
371
+ userId: pending.userId,
372
+ provider: "GARMIN",
373
+ });
374
+
375
+ // 5. Store permanent OAuth tokens
376
+ await ctx.runMutation(internalApi.garmin.storeTokens, {
377
+ connectionId,
378
+ accessToken: accessTokenResult.oauthToken,
379
+ tokenSecret: accessTokenResult.oauthTokenSecret,
380
+ });
381
+
382
+ // 6. Sync last 30 days of all data types
383
+ const client = new GarminClient({
384
+ consumerKey: args.consumerKey,
385
+ consumerSecret: args.consumerSecret,
386
+ accessToken: accessTokenResult.oauthToken,
387
+ tokenSecret: accessTokenResult.oauthTokenSecret,
388
+ });
389
+
390
+ const now = Math.floor(Date.now() / 1000);
391
+ const thirtyDaysAgo = now - DEFAULT_SYNC_DAYS * 86400;
392
+ const timeRange = {
393
+ uploadStartTimeInSeconds: thirtyDaysAgo,
394
+ uploadEndTimeInSeconds: now,
395
+ };
396
+
397
+ const result = await syncAllTypes(ctx, client, {
398
+ connectionId,
399
+ userId: pending.userId,
400
+ consumerKey: args.consumerKey,
401
+ consumerSecret: args.consumerSecret,
402
+ timeRange,
403
+ });
404
+
405
+ // 7. Update lastDataUpdate timestamp
406
+ await ctx.runMutation(publicApi.public.updateConnection, {
407
+ connectionId,
408
+ lastDataUpdate: new Date().toISOString(),
409
+ });
410
+
411
+ return {
412
+ connectionId,
413
+ synced: result.synced,
414
+ errors: result.errors,
415
+ };
416
+ },
417
+ });
418
+
242
419
  /**
243
420
  * Incremental Garmin sync for an already-connected user.
244
421
  *
@@ -125,4 +125,16 @@ export default defineSchema({
125
125
  tokenSecret: v.optional(v.string()), // OAuth 1.0a providers (Garmin)
126
126
  expiresAt: v.optional(v.number()), // Unix epoch seconds; absent for permanent tokens
127
127
  }).index("by_connectionId", ["connectionId"]),
128
+
129
+ // ── Pending OAuth ─────────────────────────────────────────────────────────
130
+ // Temporary storage for in-progress OAuth flows. Bridges the gap between
131
+ // initiating OAuth (Step 1) and the callback (Step 3) for providers like
132
+ // Garmin that use OAuth 1.0a and don't have a `state` parameter.
133
+ pendingOAuth: defineTable({
134
+ provider: v.string(),
135
+ oauthToken: v.string(),
136
+ tokenSecret: v.string(),
137
+ userId: v.string(),
138
+ createdAt: v.number(),
139
+ }).index("by_oauthToken", ["oauthToken"]),
128
140
  });