@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/src/component/garmin.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ─── Garmin Component Actions ────────────────────────────────────────────────
|
|
2
|
-
// Public actions that handle the full Garmin OAuth + sync lifecycle.
|
|
2
|
+
// Public actions that handle the full Garmin OAuth 2.0 PKCE + sync lifecycle.
|
|
3
3
|
// The host app calls these through the Soma class, which threads the
|
|
4
4
|
// credentials automatically from env vars or constructor config.
|
|
5
5
|
//
|
|
@@ -12,7 +12,14 @@ import {
|
|
|
12
12
|
internalMutation,
|
|
13
13
|
internalQuery,
|
|
14
14
|
} from "./_generated/server.js";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
generateCodeVerifier,
|
|
17
|
+
generateCodeChallenge,
|
|
18
|
+
generateState,
|
|
19
|
+
buildAuthUrl,
|
|
20
|
+
exchangeCode,
|
|
21
|
+
refreshToken,
|
|
22
|
+
} from "../garmin/auth.js";
|
|
16
23
|
import { GarminClient } from "../garmin/client.js";
|
|
17
24
|
import { transformActivity } from "../garmin/activity.js";
|
|
18
25
|
import { transformDaily } from "../garmin/daily.js";
|
|
@@ -29,15 +36,18 @@ const internalApi: any = anyApi;
|
|
|
29
36
|
// Default sync window: last 30 days
|
|
30
37
|
const DEFAULT_SYNC_DAYS = 30;
|
|
31
38
|
|
|
39
|
+
// Refresh buffer: refresh tokens 10 minutes before expiry
|
|
40
|
+
const REFRESH_BUFFER_SECONDS = 600;
|
|
41
|
+
|
|
32
42
|
// ─── Internal Pending OAuth CRUD ─────────────────────────────────────────────
|
|
33
|
-
// Temporary storage for in-progress Garmin OAuth
|
|
34
|
-
// Bridges
|
|
43
|
+
// Temporary storage for in-progress Garmin OAuth 2.0 PKCE flows.
|
|
44
|
+
// Bridges getGarminAuthUrl and completeGarminOAuth.
|
|
35
45
|
|
|
36
46
|
export const storePendingOAuth = internalMutation({
|
|
37
47
|
args: {
|
|
38
48
|
provider: v.string(),
|
|
39
|
-
|
|
40
|
-
|
|
49
|
+
state: v.string(),
|
|
50
|
+
codeVerifier: v.string(),
|
|
41
51
|
userId: v.string(),
|
|
42
52
|
},
|
|
43
53
|
returns: v.null(),
|
|
@@ -51,14 +61,14 @@ export const storePendingOAuth = internalMutation({
|
|
|
51
61
|
});
|
|
52
62
|
|
|
53
63
|
export const getPendingOAuth = internalQuery({
|
|
54
|
-
args: {
|
|
64
|
+
args: { state: v.string() },
|
|
55
65
|
returns: v.union(
|
|
56
66
|
v.object({
|
|
57
67
|
_id: v.id("pendingOAuth"),
|
|
58
68
|
_creationTime: v.number(),
|
|
59
69
|
provider: v.string(),
|
|
60
|
-
|
|
61
|
-
|
|
70
|
+
state: v.string(),
|
|
71
|
+
codeVerifier: v.string(),
|
|
62
72
|
userId: v.string(),
|
|
63
73
|
createdAt: v.number(),
|
|
64
74
|
}),
|
|
@@ -67,18 +77,18 @@ export const getPendingOAuth = internalQuery({
|
|
|
67
77
|
handler: async (ctx, args) => {
|
|
68
78
|
return await ctx.db
|
|
69
79
|
.query("pendingOAuth")
|
|
70
|
-
.withIndex("
|
|
80
|
+
.withIndex("by_state", (q) => q.eq("state", args.state))
|
|
71
81
|
.first();
|
|
72
82
|
},
|
|
73
83
|
});
|
|
74
84
|
|
|
75
85
|
export const deletePendingOAuth = internalMutation({
|
|
76
|
-
args: {
|
|
86
|
+
args: { state: v.string() },
|
|
77
87
|
returns: v.null(),
|
|
78
88
|
handler: async (ctx, args) => {
|
|
79
89
|
const pending = await ctx.db
|
|
80
90
|
.query("pendingOAuth")
|
|
81
|
-
.withIndex("
|
|
91
|
+
.withIndex("by_state", (q) => q.eq("state", args.state))
|
|
82
92
|
.first();
|
|
83
93
|
if (pending) {
|
|
84
94
|
await ctx.db.delete(pending._id);
|
|
@@ -90,14 +100,15 @@ export const deletePendingOAuth = internalMutation({
|
|
|
90
100
|
// ─── Internal Token CRUD ─────────────────────────────────────────────────────
|
|
91
101
|
|
|
92
102
|
/**
|
|
93
|
-
* Store OAuth
|
|
103
|
+
* Store OAuth 2.0 tokens for a Garmin connection.
|
|
94
104
|
* Upserts by connectionId — one token record per connection.
|
|
95
105
|
*/
|
|
96
106
|
export const storeTokens = internalMutation({
|
|
97
107
|
args: {
|
|
98
108
|
connectionId: v.id("connections"),
|
|
99
109
|
accessToken: v.string(),
|
|
100
|
-
|
|
110
|
+
refreshToken: v.string(),
|
|
111
|
+
expiresAt: v.number(),
|
|
101
112
|
},
|
|
102
113
|
returns: v.null(),
|
|
103
114
|
handler: async (ctx, args) => {
|
|
@@ -111,7 +122,8 @@ export const storeTokens = internalMutation({
|
|
|
111
122
|
if (existing) {
|
|
112
123
|
await ctx.db.patch(existing._id, {
|
|
113
124
|
accessToken: args.accessToken,
|
|
114
|
-
|
|
125
|
+
refreshToken: args.refreshToken,
|
|
126
|
+
expiresAt: args.expiresAt,
|
|
115
127
|
});
|
|
116
128
|
return null;
|
|
117
129
|
}
|
|
@@ -119,7 +131,8 @@ export const storeTokens = internalMutation({
|
|
|
119
131
|
await ctx.db.insert("providerTokens", {
|
|
120
132
|
connectionId: args.connectionId,
|
|
121
133
|
accessToken: args.accessToken,
|
|
122
|
-
|
|
134
|
+
refreshToken: args.refreshToken,
|
|
135
|
+
expiresAt: args.expiresAt,
|
|
123
136
|
});
|
|
124
137
|
return null;
|
|
125
138
|
},
|
|
@@ -137,7 +150,6 @@ export const getTokens = internalQuery({
|
|
|
137
150
|
connectionId: v.id("connections"),
|
|
138
151
|
accessToken: v.string(),
|
|
139
152
|
refreshToken: v.optional(v.string()),
|
|
140
|
-
tokenSecret: v.optional(v.string()),
|
|
141
153
|
expiresAt: v.optional(v.number()),
|
|
142
154
|
}),
|
|
143
155
|
v.null(),
|
|
@@ -176,67 +188,66 @@ export const deleteTokens = internalMutation({
|
|
|
176
188
|
// ─── Public Actions ──────────────────────────────────────────────────────────
|
|
177
189
|
|
|
178
190
|
/**
|
|
179
|
-
*
|
|
191
|
+
* Generate a Garmin OAuth 2.0 authorization URL with PKCE.
|
|
180
192
|
*
|
|
181
|
-
* If `userId` is provided, the
|
|
193
|
+
* If `userId` is provided, the PKCE code verifier and state are stored in the
|
|
182
194
|
* component's `pendingOAuth` table so that `completeGarminOAuth` can look
|
|
183
195
|
* them up automatically when the callback fires. This is the recommended
|
|
184
196
|
* flow when using `registerRoutes`.
|
|
185
197
|
*
|
|
186
|
-
* If `userId` is omitted, the host app must store the returned `
|
|
187
|
-
*
|
|
198
|
+
* If `userId` is omitted, the host app must store the returned `codeVerifier`
|
|
199
|
+
* itself and pass it to `connectGarmin` manually.
|
|
188
200
|
*/
|
|
189
|
-
export const
|
|
201
|
+
export const getGarminAuthUrl = action({
|
|
190
202
|
args: {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
callbackUrl: v.optional(v.string()),
|
|
203
|
+
clientId: v.string(),
|
|
204
|
+
redirectUri: v.optional(v.string()),
|
|
194
205
|
userId: v.optional(v.string()),
|
|
195
206
|
},
|
|
196
207
|
returns: v.object({
|
|
197
|
-
token: v.string(),
|
|
198
|
-
tokenSecret: v.string(),
|
|
199
208
|
authUrl: v.string(),
|
|
209
|
+
state: v.string(),
|
|
210
|
+
codeVerifier: v.string(),
|
|
200
211
|
}),
|
|
201
212
|
handler: async (ctx, args) => {
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
213
|
+
const codeVerifier = generateCodeVerifier();
|
|
214
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
215
|
+
const state = generateState();
|
|
216
|
+
|
|
217
|
+
const authUrl = buildAuthUrl({
|
|
218
|
+
clientId: args.clientId,
|
|
219
|
+
codeChallenge,
|
|
220
|
+
redirectUri: args.redirectUri,
|
|
221
|
+
state,
|
|
206
222
|
});
|
|
207
223
|
|
|
208
224
|
if (args.userId) {
|
|
209
225
|
await ctx.runMutation(internalApi.garmin.storePendingOAuth, {
|
|
210
226
|
provider: "GARMIN",
|
|
211
|
-
|
|
212
|
-
|
|
227
|
+
state,
|
|
228
|
+
codeVerifier,
|
|
213
229
|
userId: args.userId,
|
|
214
230
|
});
|
|
215
231
|
}
|
|
216
232
|
|
|
217
|
-
return {
|
|
218
|
-
token: result.oauthToken,
|
|
219
|
-
tokenSecret: result.oauthTokenSecret,
|
|
220
|
-
authUrl: result.authUrl,
|
|
221
|
-
};
|
|
233
|
+
return { authUrl, state, codeVerifier };
|
|
222
234
|
},
|
|
223
235
|
});
|
|
224
236
|
|
|
225
237
|
/**
|
|
226
|
-
*
|
|
238
|
+
* Exchange an authorization code for tokens + initial sync.
|
|
227
239
|
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
* the last 30 days of all data types.
|
|
240
|
+
* Used in the manual flow where the host app stores the code verifier
|
|
241
|
+
* and handles the callback itself.
|
|
231
242
|
*/
|
|
232
243
|
export const connectGarmin = action({
|
|
233
244
|
args: {
|
|
234
245
|
userId: v.string(),
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
246
|
+
clientId: v.string(),
|
|
247
|
+
clientSecret: v.string(),
|
|
248
|
+
code: v.string(),
|
|
249
|
+
codeVerifier: v.string(),
|
|
250
|
+
redirectUri: v.optional(v.string()),
|
|
240
251
|
},
|
|
241
252
|
returns: v.object({
|
|
242
253
|
connectionId: v.string(),
|
|
@@ -252,34 +263,29 @@ export const connectGarmin = action({
|
|
|
252
263
|
),
|
|
253
264
|
}),
|
|
254
265
|
handler: async (ctx, args) => {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
verifier: args.verifier,
|
|
266
|
+
const tokenResult = await exchangeCode({
|
|
267
|
+
clientId: args.clientId,
|
|
268
|
+
clientSecret: args.clientSecret,
|
|
269
|
+
code: args.code,
|
|
270
|
+
codeVerifier: args.codeVerifier,
|
|
271
|
+
redirectUri: args.redirectUri,
|
|
262
272
|
});
|
|
263
273
|
|
|
264
|
-
// 2. Create/reactivate the Soma connection
|
|
265
274
|
const connectionId = await ctx.runMutation(publicApi.public.connect, {
|
|
266
275
|
userId: args.userId,
|
|
267
276
|
provider: "GARMIN",
|
|
268
277
|
});
|
|
269
278
|
|
|
270
|
-
|
|
279
|
+
const expiresAt = Math.floor(Date.now() / 1000) + tokenResult.expires_in;
|
|
271
280
|
await ctx.runMutation(internalApi.garmin.storeTokens, {
|
|
272
281
|
connectionId,
|
|
273
|
-
accessToken:
|
|
274
|
-
|
|
282
|
+
accessToken: tokenResult.access_token,
|
|
283
|
+
refreshToken: tokenResult.refresh_token,
|
|
284
|
+
expiresAt,
|
|
275
285
|
});
|
|
276
286
|
|
|
277
|
-
// 4. Sync last 30 days of all data types
|
|
278
287
|
const client = new GarminClient({
|
|
279
|
-
|
|
280
|
-
consumerSecret: args.consumerSecret,
|
|
281
|
-
accessToken: accessTokenResult.oauthToken,
|
|
282
|
-
tokenSecret: accessTokenResult.oauthTokenSecret,
|
|
288
|
+
accessToken: tokenResult.access_token,
|
|
283
289
|
});
|
|
284
290
|
|
|
285
291
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -292,12 +298,9 @@ export const connectGarmin = action({
|
|
|
292
298
|
const result = await syncAllTypes(ctx, client, {
|
|
293
299
|
connectionId,
|
|
294
300
|
userId: args.userId,
|
|
295
|
-
consumerKey: args.consumerKey,
|
|
296
|
-
consumerSecret: args.consumerSecret,
|
|
297
301
|
timeRange,
|
|
298
302
|
});
|
|
299
303
|
|
|
300
|
-
// 5. Update lastDataUpdate timestamp
|
|
301
304
|
await ctx.runMutation(publicApi.public.updateConnection, {
|
|
302
305
|
connectionId,
|
|
303
306
|
lastDataUpdate: new Date().toISOString(),
|
|
@@ -312,20 +315,21 @@ export const connectGarmin = action({
|
|
|
312
315
|
});
|
|
313
316
|
|
|
314
317
|
/**
|
|
315
|
-
* Complete a Garmin OAuth flow using stored pending state.
|
|
318
|
+
* Complete a Garmin OAuth 2.0 flow using stored pending state.
|
|
316
319
|
*
|
|
317
320
|
* Used by `registerRoutes` — the callback handler calls this with the
|
|
318
|
-
* `
|
|
319
|
-
*
|
|
320
|
-
* exchanges for
|
|
321
|
-
*
|
|
321
|
+
* `code` and `state` from the redirect. The action looks up the pending
|
|
322
|
+
* state (codeVerifier, userId) stored during `getGarminAuthUrl`,
|
|
323
|
+
* exchanges for tokens, creates the connection, syncs data, and
|
|
324
|
+
* cleans up the pending entry.
|
|
322
325
|
*/
|
|
323
326
|
export const completeGarminOAuth = action({
|
|
324
327
|
args: {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
328
|
+
code: v.string(),
|
|
329
|
+
state: v.string(),
|
|
330
|
+
clientId: v.string(),
|
|
331
|
+
clientSecret: v.string(),
|
|
332
|
+
redirectUri: v.optional(v.string()),
|
|
329
333
|
},
|
|
330
334
|
returns: v.object({
|
|
331
335
|
connectionId: v.string(),
|
|
@@ -341,50 +345,43 @@ export const completeGarminOAuth = action({
|
|
|
341
345
|
),
|
|
342
346
|
}),
|
|
343
347
|
handler: async (ctx, args) => {
|
|
344
|
-
// 1. Look up pending state
|
|
345
348
|
const pending = await ctx.runQuery(internalApi.garmin.getPendingOAuth, {
|
|
346
|
-
|
|
349
|
+
state: args.state,
|
|
347
350
|
});
|
|
348
351
|
if (!pending) {
|
|
349
352
|
throw new Error(
|
|
350
|
-
"No pending Garmin OAuth state found for this
|
|
351
|
-
"The
|
|
353
|
+
"No pending Garmin OAuth state found for this state parameter. " +
|
|
354
|
+
"The authorization may have expired or was already used.",
|
|
352
355
|
);
|
|
353
356
|
}
|
|
354
357
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
verifier: args.oauthVerifier,
|
|
358
|
+
const tokenResult = await exchangeCode({
|
|
359
|
+
clientId: args.clientId,
|
|
360
|
+
clientSecret: args.clientSecret,
|
|
361
|
+
code: args.code,
|
|
362
|
+
codeVerifier: pending.codeVerifier,
|
|
363
|
+
redirectUri: args.redirectUri,
|
|
362
364
|
});
|
|
363
365
|
|
|
364
|
-
// 3. Delete pending state (no longer needed)
|
|
365
366
|
await ctx.runMutation(internalApi.garmin.deletePendingOAuth, {
|
|
366
|
-
|
|
367
|
+
state: args.state,
|
|
367
368
|
});
|
|
368
369
|
|
|
369
|
-
// 4. Create/reactivate the Soma connection
|
|
370
370
|
const connectionId = await ctx.runMutation(publicApi.public.connect, {
|
|
371
371
|
userId: pending.userId,
|
|
372
372
|
provider: "GARMIN",
|
|
373
373
|
});
|
|
374
374
|
|
|
375
|
-
|
|
375
|
+
const expiresAt = Math.floor(Date.now() / 1000) + tokenResult.expires_in;
|
|
376
376
|
await ctx.runMutation(internalApi.garmin.storeTokens, {
|
|
377
377
|
connectionId,
|
|
378
|
-
accessToken:
|
|
379
|
-
|
|
378
|
+
accessToken: tokenResult.access_token,
|
|
379
|
+
refreshToken: tokenResult.refresh_token,
|
|
380
|
+
expiresAt,
|
|
380
381
|
});
|
|
381
382
|
|
|
382
|
-
// 6. Sync last 30 days of all data types
|
|
383
383
|
const client = new GarminClient({
|
|
384
|
-
|
|
385
|
-
consumerSecret: args.consumerSecret,
|
|
386
|
-
accessToken: accessTokenResult.oauthToken,
|
|
387
|
-
tokenSecret: accessTokenResult.oauthTokenSecret,
|
|
384
|
+
accessToken: tokenResult.access_token,
|
|
388
385
|
});
|
|
389
386
|
|
|
390
387
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -397,12 +394,9 @@ export const completeGarminOAuth = action({
|
|
|
397
394
|
const result = await syncAllTypes(ctx, client, {
|
|
398
395
|
connectionId,
|
|
399
396
|
userId: pending.userId,
|
|
400
|
-
consumerKey: args.consumerKey,
|
|
401
|
-
consumerSecret: args.consumerSecret,
|
|
402
397
|
timeRange,
|
|
403
398
|
});
|
|
404
399
|
|
|
405
|
-
// 7. Update lastDataUpdate timestamp
|
|
406
400
|
await ctx.runMutation(publicApi.public.updateConnection, {
|
|
407
401
|
connectionId,
|
|
408
402
|
lastDataUpdate: new Date().toISOString(),
|
|
@@ -419,14 +413,14 @@ export const completeGarminOAuth = action({
|
|
|
419
413
|
/**
|
|
420
414
|
* Incremental Garmin sync for an already-connected user.
|
|
421
415
|
*
|
|
422
|
-
* Looks up the stored tokens and syncs all data
|
|
423
|
-
* time range (defaults to last 30 days).
|
|
416
|
+
* Looks up the stored tokens, refreshes if expired, and syncs all data
|
|
417
|
+
* types for the specified time range (defaults to last 30 days).
|
|
424
418
|
*/
|
|
425
419
|
export const syncGarmin = action({
|
|
426
420
|
args: {
|
|
427
421
|
userId: v.string(),
|
|
428
|
-
|
|
429
|
-
|
|
422
|
+
clientId: v.string(),
|
|
423
|
+
clientSecret: v.string(),
|
|
430
424
|
startTimeInSeconds: v.optional(v.number()),
|
|
431
425
|
endTimeInSeconds: v.optional(v.number()),
|
|
432
426
|
},
|
|
@@ -443,7 +437,6 @@ export const syncGarmin = action({
|
|
|
443
437
|
),
|
|
444
438
|
}),
|
|
445
439
|
handler: async (ctx, args) => {
|
|
446
|
-
// 1. Look up connection
|
|
447
440
|
const connection = await ctx.runQuery(
|
|
448
441
|
internalApi.private.getConnectionByProvider,
|
|
449
442
|
{ userId: args.userId, provider: "GARMIN" },
|
|
@@ -462,24 +455,42 @@ export const syncGarmin = action({
|
|
|
462
455
|
|
|
463
456
|
const connectionId = connection._id;
|
|
464
457
|
|
|
465
|
-
// 2. Get stored tokens
|
|
466
458
|
const tokenDoc = await ctx.runQuery(internalApi.garmin.getTokens, {
|
|
467
459
|
connectionId,
|
|
468
460
|
});
|
|
469
|
-
if (!tokenDoc
|
|
461
|
+
if (!tokenDoc) {
|
|
470
462
|
throw new Error(
|
|
471
463
|
"No Garmin tokens found for this connection. " +
|
|
472
464
|
"The connection may have been created before token storage was available.",
|
|
473
465
|
);
|
|
474
466
|
}
|
|
475
467
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
468
|
+
let accessToken = tokenDoc.accessToken;
|
|
469
|
+
|
|
470
|
+
// Refresh the token if it's expired or about to expire
|
|
471
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
472
|
+
if (
|
|
473
|
+
tokenDoc.expiresAt &&
|
|
474
|
+
tokenDoc.refreshToken &&
|
|
475
|
+
nowSeconds >= tokenDoc.expiresAt - REFRESH_BUFFER_SECONDS
|
|
476
|
+
) {
|
|
477
|
+
const refreshed = await refreshToken({
|
|
478
|
+
clientId: args.clientId,
|
|
479
|
+
clientSecret: args.clientSecret,
|
|
480
|
+
refreshToken: tokenDoc.refreshToken,
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
accessToken = refreshed.access_token;
|
|
484
|
+
const newExpiresAt = nowSeconds + refreshed.expires_in;
|
|
485
|
+
await ctx.runMutation(internalApi.garmin.storeTokens, {
|
|
486
|
+
connectionId,
|
|
487
|
+
accessToken: refreshed.access_token,
|
|
488
|
+
refreshToken: refreshed.refresh_token,
|
|
489
|
+
expiresAt: newExpiresAt,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const client = new GarminClient({ accessToken });
|
|
483
494
|
|
|
484
495
|
const now = Math.floor(Date.now() / 1000);
|
|
485
496
|
const timeRange = {
|
|
@@ -491,12 +502,9 @@ export const syncGarmin = action({
|
|
|
491
502
|
const result = await syncAllTypes(ctx, client, {
|
|
492
503
|
connectionId,
|
|
493
504
|
userId: args.userId,
|
|
494
|
-
consumerKey: args.consumerKey,
|
|
495
|
-
consumerSecret: args.consumerSecret,
|
|
496
505
|
timeRange,
|
|
497
506
|
});
|
|
498
507
|
|
|
499
|
-
// 4. Update lastDataUpdate timestamp
|
|
500
508
|
await ctx.runMutation(publicApi.public.updateConnection, {
|
|
501
509
|
connectionId,
|
|
502
510
|
lastDataUpdate: new Date().toISOString(),
|
|
@@ -509,8 +517,8 @@ export const syncGarmin = action({
|
|
|
509
517
|
/**
|
|
510
518
|
* Disconnect a user from Garmin.
|
|
511
519
|
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
520
|
+
* Deregisters the user via the Garmin API (best-effort), deletes stored
|
|
521
|
+
* tokens, and sets the connection to inactive.
|
|
514
522
|
*/
|
|
515
523
|
export const disconnectGarmin = action({
|
|
516
524
|
args: {
|
|
@@ -518,7 +526,6 @@ export const disconnectGarmin = action({
|
|
|
518
526
|
},
|
|
519
527
|
returns: v.null(),
|
|
520
528
|
handler: async (ctx, args) => {
|
|
521
|
-
// 1. Look up connection
|
|
522
529
|
const connection = await ctx.runQuery(
|
|
523
530
|
internalApi.private.getConnectionByProvider,
|
|
524
531
|
{ userId: args.userId, provider: "GARMIN" },
|
|
@@ -531,10 +538,21 @@ export const disconnectGarmin = action({
|
|
|
531
538
|
|
|
532
539
|
const connectionId = connection._id;
|
|
533
540
|
|
|
534
|
-
//
|
|
541
|
+
// Best-effort: deregister user at Garmin
|
|
542
|
+
const tokenDoc = await ctx.runQuery(internalApi.garmin.getTokens, {
|
|
543
|
+
connectionId,
|
|
544
|
+
});
|
|
545
|
+
if (tokenDoc) {
|
|
546
|
+
try {
|
|
547
|
+
const client = new GarminClient({ accessToken: tokenDoc.accessToken });
|
|
548
|
+
await client.deleteUserRegistration();
|
|
549
|
+
} catch {
|
|
550
|
+
// Deregistration is best-effort; proceed with local cleanup
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
535
554
|
await ctx.runMutation(internalApi.garmin.deleteTokens, { connectionId });
|
|
536
555
|
|
|
537
|
-
// 3. Set connection inactive
|
|
538
556
|
await ctx.runMutation(publicApi.public.disconnect, {
|
|
539
557
|
userId: args.userId,
|
|
540
558
|
provider: "GARMIN",
|
|
@@ -549,8 +567,6 @@ export const disconnectGarmin = action({
|
|
|
549
567
|
interface SyncAllConfig {
|
|
550
568
|
connectionId: string;
|
|
551
569
|
userId: string;
|
|
552
|
-
consumerKey: string;
|
|
553
|
-
consumerSecret: string;
|
|
554
570
|
timeRange: { uploadStartTimeInSeconds: number; uploadEndTimeInSeconds: number };
|
|
555
571
|
}
|
|
556
572
|
|
package/src/component/schema.ts
CHANGED
|
@@ -115,26 +115,25 @@ export default defineSchema({
|
|
|
115
115
|
.index("by_userId_plannedDate", ["userId", "metadata.planned_date"]),
|
|
116
116
|
|
|
117
117
|
// ── Provider Tokens ────────────────────────────────────────────────────────
|
|
118
|
-
// OAuth tokens for cloud-based providers (Strava, Garmin, etc.).
|
|
118
|
+
// OAuth 2.0 tokens for cloud-based providers (Strava, Garmin, etc.).
|
|
119
119
|
// Stored separately from connections to keep the connection table
|
|
120
120
|
// provider-agnostic. One token record per connection.
|
|
121
121
|
providerTokens: defineTable({
|
|
122
122
|
connectionId: v.id("connections"),
|
|
123
123
|
accessToken: v.string(),
|
|
124
|
-
refreshToken: v.optional(v.string()),
|
|
125
|
-
|
|
126
|
-
expiresAt: v.optional(v.number()), // Unix epoch seconds; absent for permanent tokens
|
|
124
|
+
refreshToken: v.optional(v.string()),
|
|
125
|
+
expiresAt: v.optional(v.number()),
|
|
127
126
|
}).index("by_connectionId", ["connectionId"]),
|
|
128
127
|
|
|
129
128
|
// ── Pending OAuth ─────────────────────────────────────────────────────────
|
|
130
|
-
// Temporary storage for in-progress OAuth flows. Bridges the gap
|
|
131
|
-
// initiating OAuth (
|
|
132
|
-
//
|
|
129
|
+
// Temporary storage for in-progress OAuth 2.0 PKCE flows. Bridges the gap
|
|
130
|
+
// between initiating OAuth (auth URL) and the callback (code exchange).
|
|
131
|
+
// The `state` parameter links the callback back to the pending entry.
|
|
133
132
|
pendingOAuth: defineTable({
|
|
134
133
|
provider: v.string(),
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
state: v.string(),
|
|
135
|
+
codeVerifier: v.string(),
|
|
137
136
|
userId: v.string(),
|
|
138
137
|
createdAt: v.number(),
|
|
139
|
-
}).index("
|
|
138
|
+
}).index("by_state", ["state"]),
|
|
140
139
|
});
|
package/src/component/strava.ts
CHANGED