@kaiord/garmin-connect 5.0.0 → 7.0.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/dist/index.js CHANGED
@@ -1,80 +1,104 @@
1
- import { createConsoleLogger, createServiceAuthError, createServiceApiError, toText } from '@kaiord/core';
1
+ import { createConsoleLogger, createServiceAuthError, createServiceApiError, fromText, toText } from '@kaiord/core';
2
2
  import fetchCookie from 'fetch-cookie';
3
3
  import { createHmac } from 'crypto';
4
4
  import OAuth from 'oauth-1.0a';
5
5
  import { z } from 'zod';
6
- import { createGarminWriter } from '@kaiord/garmin';
6
+ import { createGarminWriter, createGarminReader } from '@kaiord/garmin';
7
7
  import { unlink, readFile, mkdir, writeFile } from 'fs/promises';
8
8
  import { homedir } from 'os';
9
9
  import { join, dirname } from 'path';
10
10
 
11
11
  // src/adapters/auth/garmin-auth-provider.ts
12
12
  var createCookieFetch = () => fetchCookie(globalThis.fetch);
13
- var makeRequest = (url, init, refresh, fetchFn) => {
14
- if (!refresh.state.oauth2Token) {
15
- throw createServiceApiError("Token unavailable after refresh", 401);
16
- }
17
- return fetchFn(url, {
18
- ...init,
19
- headers: {
20
- ...init?.headers,
21
- Authorization: `Bearer ${refresh.state.oauth2Token.access_token}`
22
- }
23
- });
24
- };
25
- var authFetch = async (url, init, refresh, fetchFn) => {
26
- if (!refresh.state.oauth2Token) {
27
- throw createServiceApiError("Not authenticated", 401);
28
- }
29
- if (refresh.state.oauth2Token.expires_at < Math.floor(Date.now() / 1e3)) {
30
- await refresh.ensureFreshToken();
31
- }
32
- const res = await makeRequest(url, init, refresh, fetchFn);
33
- if (res.status === 401) {
34
- await refresh.ensureFreshToken();
35
- const retry = await makeRequest(url, init, refresh, fetchFn);
36
- if (!retry.ok) {
37
- throw createServiceApiError(
38
- `API request failed after token refresh: ${retry.statusText}`,
39
- retry.status
40
- );
41
- }
42
- return retry;
43
- }
44
- if (!res.ok) {
45
- throw createServiceApiError(
46
- `API request failed: ${res.statusText}`,
47
- res.status
48
- );
49
- }
50
- return res;
51
- };
52
13
 
53
14
  // src/adapters/http/urls.ts
54
15
  var GARMIN_SSO_ORIGIN = "https://sso.garmin.com";
55
16
  var GARMIN_SSO_EMBED = "https://sso.garmin.com/sso/embed";
56
17
  var SIGNIN_URL = "https://sso.garmin.com/sso/signin";
57
- var GC_MODERN = "https://connect.garmin.com/modern";
58
18
  var API_BASE = "https://connectapi.garmin.com";
59
19
  var OAUTH_URL = `${API_BASE}/oauth-service/oauth`;
60
20
  var WORKOUT_URL = `${API_BASE}/workout-service`;
61
21
  var OAUTH_CONSUMER_URL = "https://thegarth.s3.amazonaws.com/oauth_consumer.json";
62
22
  var USER_AGENT_MOBILE = "com.garmin.android.apps.connectmobile";
63
- var USER_AGENT_BROWSER = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36";
23
+ var USER_AGENT_SSO = "GCM-iOS-5.7.2.1";
64
24
 
65
25
  // src/adapters/http/oauth-consumer.ts
66
- var fetchOAuthConsumer = async (fetchFn) => {
26
+ var fetchOAuthConsumer = async (fetchFn, logger) => {
27
+ logger.debug("[SSO] Fetching OAuth consumer credentials");
67
28
  const res = await fetchFn(OAUTH_CONSUMER_URL);
68
29
  if (!res.ok) {
30
+ logger.error("[SSO] OAuth consumer fetch failed", { status: res.status });
69
31
  throw createServiceAuthError(
70
32
  `Failed to fetch OAuth consumer: ${res.status} ${res.statusText}`
71
33
  );
72
34
  }
35
+ logger.debug("[SSO] OAuth consumer fetch", { status: res.status });
73
36
  const data = await res.json();
74
37
  return { key: data.consumer_key, secret: data.consumer_secret };
75
38
  };
76
- var ACCOUNT_LOCKED_RE = /var status\s*=\s*"([^"]*)"/;
39
+
40
+ // src/adapters/http/sso-html-diagnostics.ts
77
41
  var PAGE_TITLE_RE = /<title>([^<]*)<\/title>/;
42
+ var extractTitle = (html) => {
43
+ const match = PAGE_TITLE_RE.exec(html);
44
+ return match?.[1] ?? "unknown";
45
+ };
46
+ var logCsrfResult = (logger, status, size, found) => {
47
+ logger.debug("[SSO] CSRF fetch", { status, size });
48
+ if (!found) logger.warn("[SSO] CSRF token not found in response");
49
+ };
50
+ var logLoginResponse = (logger, status, size) => {
51
+ logger.debug("[SSO] Login response", { status, size });
52
+ };
53
+ var logLoginHtmlDiagnostics = (html, ticketFound, logger) => {
54
+ const title = extractTitle(html);
55
+ const size = new TextEncoder().encode(html).byteLength;
56
+ logger.debug("[SSO] Page title", { title });
57
+ if (/\bmfa\b/i.test(html)) logger.warn("[SSO] MFA detected");
58
+ if (/\berror\b/i.test(html)) logger.warn("[SSO] Error indicators found");
59
+ if (ticketFound) {
60
+ logger.debug("[SSO] Ticket found in HTML");
61
+ } else {
62
+ logger.warn("[SSO] Ticket not found in HTML", { size, title });
63
+ }
64
+ };
65
+
66
+ // src/adapters/http/sso-submit.ts
67
+ var buildLoginParams = () => new URLSearchParams({
68
+ id: "gauth-widget",
69
+ embedWidget: "true",
70
+ gauthHost: GARMIN_SSO_EMBED,
71
+ service: GARMIN_SSO_EMBED,
72
+ source: GARMIN_SSO_EMBED,
73
+ redirectAfterAccountLoginUrl: GARMIN_SSO_EMBED,
74
+ redirectAfterAccountCreationUrl: GARMIN_SSO_EMBED
75
+ });
76
+ var submitLogin = async (input) => {
77
+ const { username, password, csrf, fetchFn, logger } = input;
78
+ logger.debug("[SSO] Submitting login");
79
+ const body = new URLSearchParams({
80
+ username,
81
+ password,
82
+ embed: "true",
83
+ _csrf: csrf
84
+ });
85
+ const loginRes = await fetchFn(`${SIGNIN_URL}?${buildLoginParams()}`, {
86
+ method: "POST",
87
+ headers: {
88
+ "Content-Type": "application/x-www-form-urlencoded",
89
+ Origin: GARMIN_SSO_ORIGIN,
90
+ Referer: `${SIGNIN_URL}?${buildLoginParams()}`,
91
+ "User-Agent": USER_AGENT_SSO
92
+ },
93
+ body: body.toString()
94
+ });
95
+ const html = await loginRes.text();
96
+ const size = new TextEncoder().encode(html).byteLength;
97
+ logLoginResponse(logger, loginRes.status, size);
98
+ return { html, status: loginRes.status };
99
+ };
100
+ var ACCOUNT_LOCKED_RE = /var status\s*=\s*"([^"]*)"/;
101
+ var PAGE_TITLE_RE2 = /<title>([^<]*)<\/title>/;
78
102
  var checkAccountLocked = (html) => {
79
103
  const match = ACCOUNT_LOCKED_RE.exec(html);
80
104
  if (match && match[1] === "ACCOUNT_LOCKED") {
@@ -84,7 +108,7 @@ var checkAccountLocked = (html) => {
84
108
  }
85
109
  };
86
110
  var checkPageTitle = (html, logger) => {
87
- const match = PAGE_TITLE_RE.exec(html);
111
+ const match = PAGE_TITLE_RE2.exec(html);
88
112
  if (match?.[1]?.includes("Update Phone Number")) {
89
113
  throw createServiceAuthError("Login failed: phone number update required.");
90
114
  }
@@ -96,69 +120,66 @@ var checkPageTitle = (html, logger) => {
96
120
  // src/adapters/http/sso-login.ts
97
121
  var CSRF_RE = /name="_csrf"\s+value="(.+?)"/;
98
122
  var TICKET_RE = /ticket=([^"]+)"/;
99
- var fetchCsrfToken = async (fetchFn) => {
100
- const signinParams = new URLSearchParams({
101
- id: "gauth-widget",
102
- embedWidget: "true",
103
- locale: "en",
104
- gauthHost: GARMIN_SSO_EMBED
123
+ var buildSigninParams = () => new URLSearchParams({
124
+ id: "gauth-widget",
125
+ embedWidget: "true",
126
+ gauthHost: GARMIN_SSO_EMBED,
127
+ service: GARMIN_SSO_EMBED,
128
+ source: GARMIN_SSO_EMBED,
129
+ redirectAfterAccountLoginUrl: GARMIN_SSO_EMBED,
130
+ redirectAfterAccountCreationUrl: GARMIN_SSO_EMBED
131
+ });
132
+ var fetchCsrfToken = async (fetchFn, embedUrl, logger) => {
133
+ logger.debug("[SSO] Fetching CSRF token");
134
+ const signinParams = buildSigninParams();
135
+ const csrfRes = await fetchFn(`${SIGNIN_URL}?${signinParams}`, {
136
+ headers: {
137
+ "User-Agent": USER_AGENT_SSO,
138
+ Referer: embedUrl
139
+ }
105
140
  });
106
- const csrfRes = await fetchFn(`${SIGNIN_URL}?${signinParams}`);
141
+ const csrfHtml = await csrfRes.text();
142
+ const csrfMatch = CSRF_RE.exec(csrfHtml);
143
+ const size = new TextEncoder().encode(csrfHtml).byteLength;
144
+ logCsrfResult(logger, csrfRes.status, size, !!csrfMatch);
107
145
  if (!csrfRes.ok) {
108
146
  throw createServiceAuthError(
109
147
  `SSO login page returned ${csrfRes.status}: ${csrfRes.statusText}`
110
148
  );
111
149
  }
112
- const csrfHtml = await csrfRes.text();
113
- const csrfMatch = CSRF_RE.exec(csrfHtml);
114
150
  if (!csrfMatch) {
115
151
  throw createServiceAuthError("CSRF token not found on login page");
116
152
  }
117
153
  return csrfMatch[1];
118
154
  };
119
- var submitLogin = async (username, password, csrf, fetchFn) => {
120
- const loginParams = new URLSearchParams({
155
+ var getLoginTicket = async (username, password, fetchFn, logger) => {
156
+ const embedParams = new URLSearchParams({
121
157
  id: "gauth-widget",
122
158
  embedWidget: "true",
123
- clientId: "GarminConnect",
124
- locale: "en",
125
- gauthHost: GARMIN_SSO_EMBED,
126
- service: GARMIN_SSO_EMBED,
127
- source: GARMIN_SSO_EMBED,
128
- redirectAfterAccountLoginUrl: GARMIN_SSO_EMBED,
129
- redirectAfterAccountCreationUrl: GARMIN_SSO_EMBED
159
+ gauthHost: "https://sso.garmin.com/sso"
130
160
  });
131
- const body = new URLSearchParams({
161
+ const embedUrl = `${GARMIN_SSO_EMBED}?${embedParams}`;
162
+ const embedRes = await fetchFn(embedUrl, {
163
+ headers: { "User-Agent": USER_AGENT_SSO }
164
+ });
165
+ logger.debug("[SSO] Embed bootstrap", { status: embedRes.status });
166
+ if (!embedRes.ok) {
167
+ throw createServiceAuthError(
168
+ `SSO embed bootstrap failed: ${embedRes.status} ${embedRes.statusText}`
169
+ );
170
+ }
171
+ const csrf = await fetchCsrfToken(fetchFn, embedUrl, logger);
172
+ const { html: loginHtml } = await submitLogin({
132
173
  username,
133
174
  password,
134
- embed: "true",
135
- _csrf: csrf
136
- });
137
- const loginRes = await fetchFn(`${SIGNIN_URL}?${loginParams}`, {
138
- method: "POST",
139
- headers: {
140
- "Content-Type": "application/x-www-form-urlencoded",
141
- Dnt: "1",
142
- Origin: GARMIN_SSO_ORIGIN,
143
- Referer: SIGNIN_URL,
144
- "User-Agent": USER_AGENT_BROWSER
145
- },
146
- body: body.toString()
147
- });
148
- return loginRes.text();
149
- };
150
- var getLoginTicket = async (username, password, fetchFn, logger) => {
151
- const embedParams = new URLSearchParams({
152
- clientId: "GarminConnect",
153
- locale: "en",
154
- service: GC_MODERN
175
+ csrf,
176
+ fetchFn,
177
+ logger
155
178
  });
156
- await fetchFn(`${GARMIN_SSO_EMBED}?${embedParams}`);
157
- const csrf = await fetchCsrfToken(fetchFn);
158
- const loginHtml = await submitLogin(username, password, csrf, fetchFn);
159
179
  checkAccountLocked(loginHtml);
160
180
  checkPageTitle(loginHtml, logger);
161
181
  const ticketMatch = TICKET_RE.exec(loginHtml);
182
+ logLoginHtmlDiagnostics(loginHtml, !!ticketMatch, logger);
162
183
  if (!ticketMatch) {
163
184
  throw createServiceAuthError(
164
185
  "Login failed: ticket not found. Check username and password."
@@ -184,7 +205,7 @@ var createOAuthSigner = (consumer) => {
184
205
  };
185
206
 
186
207
  // src/adapters/http/sso-oauth.ts
187
- var getOAuth1Token = async (ticket, consumer, fetchFn) => {
208
+ var getOAuth1Token = async (ticket, consumer, fetchFn, logger) => {
188
209
  const signer = createOAuthSigner(consumer);
189
210
  const params = new URLSearchParams({
190
211
  ticket,
@@ -196,6 +217,7 @@ var getOAuth1Token = async (ticket, consumer, fetchFn) => {
196
217
  const res = await fetchFn(url, {
197
218
  headers: { ...headers, "User-Agent": USER_AGENT_MOBILE }
198
219
  });
220
+ logger.debug("[SSO] OAuth1 token response", { status: res.status });
199
221
  if (!res.ok) {
200
222
  throw createServiceAuthError(
201
223
  `OAuth1 token request failed: ${res.status} ${res.statusText}`
@@ -210,7 +232,7 @@ var getOAuth1Token = async (ticket, consumer, fetchFn) => {
210
232
  }
211
233
  return { oauth_token: oauthToken, oauth_token_secret: oauthTokenSecret };
212
234
  };
213
- var exchangeOAuth2 = async (oauth1, consumer, fetchFn) => {
235
+ var exchangeOAuth2 = async (oauth1, consumer, fetchFn, logger) => {
214
236
  const signer = createOAuthSigner(consumer);
215
237
  const baseUrl = `${OAUTH_URL}/exchange/user/2.0`;
216
238
  const token = {
@@ -226,6 +248,7 @@ var exchangeOAuth2 = async (oauth1, consumer, fetchFn) => {
226
248
  "Content-Type": "application/x-www-form-urlencoded"
227
249
  }
228
250
  });
251
+ logger.debug("[SSO] OAuth2 exchange response", { status: res.status });
229
252
  if (!res.ok) {
230
253
  throw createServiceAuthError(
231
254
  `OAuth2 exchange failed: ${res.status} ${res.statusText}`
@@ -241,74 +264,179 @@ var exchangeOAuth2 = async (oauth1, consumer, fetchFn) => {
241
264
  // src/adapters/http/garmin-sso.ts
242
265
  var garminSso = async (username, password, logger, fetchFn) => {
243
266
  logger.info("Starting Garmin Connect SSO login");
244
- const consumer = await fetchOAuthConsumer(fetchFn);
267
+ logger.info("[SSO] Step 1/4: OAuth consumer");
268
+ const consumer = await fetchOAuthConsumer(fetchFn, logger);
269
+ logger.info("[SSO] Step 2/4: Login ticket");
245
270
  const ticket = await getLoginTicket(username, password, fetchFn, logger);
246
- logger.debug("SSO ticket obtained");
247
- const oauth1 = await getOAuth1Token(ticket, consumer, fetchFn);
248
- logger.debug("OAuth1 token obtained");
249
- const oauth2 = await exchangeOAuth2(oauth1, consumer, fetchFn);
271
+ logger.info("[SSO] Step 3/4: OAuth1 token");
272
+ const oauth1 = await getOAuth1Token(ticket, consumer, fetchFn, logger);
273
+ logger.info("[SSO] Step 4/4: OAuth2 exchange");
274
+ const oauth2 = await exchangeOAuth2(oauth1, consumer, fetchFn, logger);
250
275
  logger.info("Garmin Connect SSO login successful");
251
276
  return { oauth1, oauth2 };
252
277
  };
278
+ var oauth1TokenSchema = z.object({
279
+ oauth_token: z.string(),
280
+ oauth_token_secret: z.string()
281
+ });
282
+ var oauth2TokenSchema = z.object({
283
+ access_token: z.string(),
284
+ refresh_token: z.string(),
285
+ token_type: z.string(),
286
+ expires_in: z.number(),
287
+ refresh_token_expires_in: z.number(),
288
+ expires_at: z.number()
289
+ });
290
+ var garminTokensSchema = z.object({
291
+ oauth1: oauth1TokenSchema,
292
+ oauth2: oauth2TokenSchema
293
+ });
253
294
 
254
- // src/adapters/http/token-refresh.ts
255
- var notifyAll = (subs, token) => {
256
- subs.forEach((s) => s.resolve(token));
257
- };
258
- var rejectAll = (subs, error) => {
259
- subs.forEach((s) => s.reject(error));
295
+ // src/adapters/auth/garmin-auth-provider.ts
296
+ var buildAuthProvider = (tm, logger, fetchFn) => ({
297
+ login: async (username, password) => {
298
+ const result = await garminSso(username, password, logger, fetchFn);
299
+ await tm.setTokens(result.oauth1, result.oauth2);
300
+ },
301
+ is_authenticated: () => tm.isAuthenticated(),
302
+ export_tokens: async () => {
303
+ const oauth1 = tm.getOAuth1Token();
304
+ const oauth2 = tm.getOAuth2Token();
305
+ if (!oauth1 || !oauth2) {
306
+ throw createServiceAuthError("No tokens to export");
307
+ }
308
+ return { oauth1: { ...oauth1 }, oauth2: { ...oauth2 } };
309
+ },
310
+ restore_tokens: async (data) => {
311
+ const parsed = garminTokensSchema.parse(data);
312
+ await tm.setTokens(parsed.oauth1, parsed.oauth2);
313
+ logger.info("Tokens restored from stored session");
314
+ },
315
+ logout: async () => {
316
+ await tm.clearTokens();
317
+ logger.info("Logged out from Garmin Connect");
318
+ }
319
+ });
320
+ var createGarminAuthProvider = (options) => {
321
+ const logger = options.logger ?? createConsoleLogger();
322
+ const fetchFn = options.fetchFn ?? createCookieFetch();
323
+ return buildAuthProvider(options.tokenManager, logger, fetchFn);
260
324
  };
261
- var getOrFetchConsumer = async (state, fetchFn) => {
262
- if (state.consumer) return state.consumer;
263
- state.consumer = await fetchOAuthConsumer(fetchFn);
264
- return state.consumer;
325
+ var persistBestEffort = async (store, oauth1, oauth2, logger) => {
326
+ if (!store) return;
327
+ try {
328
+ await store.save({ oauth1, oauth2 });
329
+ } catch (error) {
330
+ logger.warn("Failed to persist tokens", {
331
+ errorName: error instanceof Error ? error.name : typeof error
332
+ });
333
+ }
265
334
  };
266
- var doRefreshToken = async (state, fetchFn, logger) => {
267
- if (!state.oauth1Token || !state.oauth2Token) {
268
- throw createServiceApiError("No tokens available for refresh", 401);
335
+ var isExpired = (oauth2) => !oauth2 || oauth2.expires_at <= Date.now() / 1e3;
336
+ var doRefresh = (s, refreshFn, logger, tokenStore) => {
337
+ if (!s.oauth1) {
338
+ throw createServiceApiError("No OAuth1 token for refresh", 401);
269
339
  }
270
- const cons = await getOrFetchConsumer(state, fetchFn);
271
- state.oauth2Token = await exchangeOAuth2(state.oauth1Token, cons, fetchFn);
272
- logger.info("OAuth2 token refreshed");
273
- };
274
- var createTokenRefreshManager = (fetchFn, logger) => {
275
- const state = {
276
- oauth1Token: void 0,
277
- oauth2Token: void 0,
278
- consumer: void 0
340
+ const currentOAuth1 = s.oauth1;
341
+ const generationAtStart = s.generation;
342
+ s.refreshPromise = refreshFn(currentOAuth1).then(async (newOAuth2) => {
343
+ if (s.generation !== generationAtStart) return;
344
+ s.oauth2 = newOAuth2;
345
+ s.generation++;
346
+ logger.info("Token refreshed", { generation: s.generation });
347
+ await persistBestEffort(tokenStore, currentOAuth1, newOAuth2, logger);
348
+ }).finally(() => {
349
+ s.refreshPromise = void 0;
350
+ });
351
+ return s.refreshPromise;
352
+ };
353
+ var restoreFromStore = async (s, tokenStore, logger) => {
354
+ const data = await tokenStore.load();
355
+ if (!data) return { restored: false };
356
+ if (s.oauth1 && s.oauth2) return { restored: false };
357
+ const parsed = garminTokensSchema.safeParse(data);
358
+ if (!parsed.success) return { restored: false };
359
+ s.oauth1 = parsed.data.oauth1;
360
+ s.oauth2 = parsed.data.oauth2;
361
+ s.generation++;
362
+ if (isExpired(s.oauth2)) logger.warn("Restored tokens are expired");
363
+ logger.info("Tokens restored from store", { generation: s.generation });
364
+ return { restored: true };
365
+ };
366
+
367
+ // src/adapters/token/token-manager.ts
368
+ var createTokenManager = (options) => {
369
+ const { refreshFn, logger, tokenStore } = options;
370
+ const s = {
371
+ oauth1: void 0,
372
+ oauth2: void 0,
373
+ generation: 0,
374
+ refreshPromise: void 0
279
375
  };
280
- let isRefreshing = false;
281
- let subscribers = [];
282
376
  return {
283
- state,
284
- ensureFreshToken: async () => {
285
- if (isRefreshing) {
286
- await new Promise((resolve, reject) => {
287
- subscribers.push({ resolve, reject });
288
- });
289
- return;
290
- }
291
- isRefreshing = true;
292
- try {
293
- await doRefreshToken(state, fetchFn, logger);
294
- if (state.oauth2Token)
295
- notifyAll(subscribers, state.oauth2Token.access_token);
296
- subscribers = [];
297
- } catch (error) {
298
- rejectAll(subscribers, error);
299
- subscribers = [];
300
- throw error;
301
- } finally {
302
- isRefreshing = false;
303
- }
377
+ getAccessToken: () => s.oauth2?.access_token,
378
+ getOAuth1Token: () => s.oauth1 ? { ...s.oauth1 } : void 0,
379
+ getOAuth2Token: () => s.oauth2 ? { ...s.oauth2 } : void 0,
380
+ getGeneration: () => s.generation,
381
+ isAuthenticated: () => !!s.oauth2 && !isExpired(s.oauth2),
382
+ setTokens: async (o1, o2) => {
383
+ s.oauth1 = o1;
384
+ s.oauth2 = o2;
385
+ s.generation++;
386
+ logger.info("Tokens set", { generation: s.generation });
387
+ await persistBestEffort(tokenStore, o1, o2, logger);
388
+ },
389
+ clearTokens: async () => {
390
+ s.oauth1 = void 0;
391
+ s.oauth2 = void 0;
392
+ s.refreshPromise = void 0;
393
+ s.generation++;
394
+ logger.info("Tokens cleared");
395
+ if (tokenStore) await tokenStore.clear();
396
+ },
397
+ refresh: async () => {
398
+ if (s.refreshPromise) return s.refreshPromise;
399
+ return doRefresh(s, refreshFn, logger, tokenStore);
400
+ },
401
+ init: async () => {
402
+ if (s.oauth1 && s.oauth2) return { restored: false };
403
+ if (!tokenStore) return { restored: false };
404
+ return restoreFromStore(s, tokenStore, logger);
304
405
  }
305
406
  };
306
407
  };
408
+ var sendRequest = (url, init, token, fetchFn) => fetchFn(url, {
409
+ ...init,
410
+ headers: { ...init?.headers, Authorization: `Bearer ${token}` }
411
+ });
412
+ var getTokenOrThrow = (reader, msg) => {
413
+ const token = reader.getAccessToken();
414
+ if (!token) throw createServiceApiError(msg, 401);
415
+ return token;
416
+ };
417
+ var handleNonOk = (res, prefix) => {
418
+ throw createServiceApiError(`${prefix}: ${res.statusText}`, res.status);
419
+ };
420
+ var authFetch = async (url, init, reader, fetchFn) => {
421
+ if (!reader.isAuthenticated()) await reader.refresh();
422
+ const gen = reader.getGeneration();
423
+ const token = getTokenOrThrow(reader, "Not authenticated");
424
+ const res = await sendRequest(url, init, token, fetchFn);
425
+ if (res.status !== 401) {
426
+ if (!res.ok) handleNonOk(res, "API request failed");
427
+ return res;
428
+ }
429
+ if (reader.getGeneration() === gen) await reader.refresh();
430
+ const freshToken = getTokenOrThrow(reader, "Token unavailable after refresh");
431
+ const retry = await sendRequest(url, init, freshToken, fetchFn);
432
+ if (!retry.ok) handleNonOk(retry, "API request failed after token refresh");
433
+ return retry;
434
+ };
307
435
 
308
436
  // src/adapters/http/garmin-http-client.ts
309
- var createGarminHttpClient = (logger, fetchFn = globalThis.fetch) => {
310
- const refresh = createTokenRefreshManager(fetchFn, logger);
311
- const fetch = (url, init) => authFetch(url, init, refresh, fetchFn);
437
+ var createGarminHttpClient = (tokenReader, fetchFn, logger) => {
438
+ const fetch = (url, init) => authFetch(url, init, tokenReader, fetchFn);
439
+ logger.debug("HTTP client created");
312
440
  return {
313
441
  get: async (url) => {
314
442
  const res = await fetch(url);
@@ -329,88 +457,80 @@ var createGarminHttpClient = (logger, fetchFn = globalThis.fetch) => {
329
457
  });
330
458
  const text = await res.text();
331
459
  return text ? JSON.parse(text) : void 0;
332
- },
333
- setTokens: (o1, o2) => {
334
- refresh.state.oauth1Token = o1;
335
- refresh.state.oauth2Token = o2;
336
- },
337
- clearTokens: () => {
338
- refresh.state.oauth1Token = void 0;
339
- refresh.state.oauth2Token = void 0;
340
- refresh.state.consumer = void 0;
341
- },
342
- getOAuth2Token: () => refresh.state.oauth2Token
460
+ }
343
461
  };
344
462
  };
345
- var oauth1TokenSchema = z.object({
346
- oauth_token: z.string(),
347
- oauth_token_secret: z.string()
348
- });
349
- var oauth2TokenSchema = z.object({
350
- access_token: z.string(),
351
- refresh_token: z.string(),
352
- token_type: z.string(),
353
- expires_in: z.number(),
354
- refresh_token_expires_in: z.number(),
355
- expires_at: z.number()
356
- });
357
- var garminTokensSchema = z.object({
358
- oauth1: oauth1TokenSchema,
359
- oauth2: oauth2TokenSchema
360
- });
361
463
 
362
- // src/adapters/auth/garmin-auth-provider.ts
363
- var buildAuthProvider = (ctx) => ({
364
- login: async (username, password) => {
365
- const result = await garminSso(username, password, ctx.logger, ctx.fetchFn);
366
- ctx.state.oauth1 = result.oauth1;
367
- ctx.httpClient.setTokens(result.oauth1, result.oauth2);
368
- if (ctx.tokenStore) {
369
- await ctx.tokenStore.save({
370
- oauth1: result.oauth1,
371
- oauth2: result.oauth2
372
- });
464
+ // src/adapters/http/retry.ts
465
+ var isRetryable = (status) => status === 429 || status >= 500 && status <= 599;
466
+ var computeDelay = (attempt, baseDelay, maxDelay, randomFn) => randomFn() * Math.min(maxDelay, baseDelay * 2 ** attempt);
467
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
468
+ var waitAndLog = (opts, attempt, message, info) => {
469
+ const delay = computeDelay(
470
+ attempt,
471
+ opts.baseDelay,
472
+ opts.maxDelay,
473
+ opts.randomFn
474
+ );
475
+ opts.logger?.debug(message, { ...info, delay: Math.round(delay) });
476
+ return sleep(delay);
477
+ };
478
+ var handleRetryableResponse = async (attempt, response, opts) => {
479
+ if (attempt < opts.maxRetries && isRetryable(response.status)) {
480
+ await waitAndLog(opts, attempt, "Retrying request", {
481
+ attempt: attempt + 1,
482
+ status: response.status
483
+ });
484
+ return true;
485
+ }
486
+ return false;
487
+ };
488
+ var handleRetryableError = async (attempt, error, opts) => {
489
+ if (attempt < opts.maxRetries && error instanceof TypeError) {
490
+ await waitAndLog(opts, attempt, "Retrying request after network error", {
491
+ attempt: attempt + 1,
492
+ error: error.message
493
+ });
494
+ return true;
495
+ }
496
+ return false;
497
+ };
498
+ var withRetry = (fetchFn, options) => {
499
+ const opts = {
500
+ maxRetries: options?.maxRetries ?? 3,
501
+ baseDelay: options?.baseDelay ?? 1e3,
502
+ maxDelay: options?.maxDelay ?? 1e4,
503
+ randomFn: options?.randomFn ?? Math.random,
504
+ logger: options?.logger
505
+ };
506
+ return async (input, init) => {
507
+ for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
508
+ try {
509
+ const response = await fetchFn(input, init);
510
+ if (await handleRetryableResponse(attempt, response, opts)) continue;
511
+ return response;
512
+ } catch (error) {
513
+ if (await handleRetryableError(attempt, error, opts)) continue;
514
+ throw error;
515
+ }
373
516
  }
374
- },
375
- is_authenticated: () => {
376
- const token = ctx.httpClient.getOAuth2Token();
377
- if (!token) return false;
378
- return token.expires_at > Math.floor(Date.now() / 1e3);
379
- },
380
- export_tokens: async () => {
381
- const token = ctx.httpClient.getOAuth2Token();
382
- if (!token || !ctx.state.oauth1) {
383
- throw createServiceAuthError("No tokens to export");
517
+ throw new Error("Unexpected retry exhaustion");
518
+ };
519
+ };
520
+
521
+ // src/adapters/client/build-refresh-fn.ts
522
+ var buildRefreshFn = (fetchFn, logger) => {
523
+ let consumer;
524
+ return async (oauth1) => {
525
+ consumer ??= await fetchOAuthConsumer(fetchFn, logger);
526
+ try {
527
+ return await exchangeOAuth2(oauth1, consumer, fetchFn, logger);
528
+ } catch {
529
+ consumer = void 0;
530
+ consumer = await fetchOAuthConsumer(fetchFn, logger);
531
+ return exchangeOAuth2(oauth1, consumer, fetchFn, logger);
384
532
  }
385
- return { oauth1: ctx.state.oauth1, oauth2: token };
386
- },
387
- restore_tokens: async (data) => {
388
- const parsed = garminTokensSchema.parse(data);
389
- ctx.state.oauth1 = parsed.oauth1;
390
- ctx.httpClient.setTokens(parsed.oauth1, parsed.oauth2);
391
- ctx.logger.info("Tokens restored from stored session");
392
- },
393
- logout: async () => {
394
- ctx.state.oauth1 = void 0;
395
- ctx.httpClient.clearTokens();
396
- if (ctx.tokenStore) await ctx.tokenStore.clear();
397
- ctx.logger.info("Logged out from Garmin Connect");
398
- }
399
- });
400
- var createGarminAuthProvider = (options) => {
401
- const logger = options?.logger ?? createConsoleLogger();
402
- const tokenStore = options?.tokenStore;
403
- const fetchFn = options?.fetchFn ?? createCookieFetch();
404
- const httpClient = createGarminHttpClient(logger, fetchFn);
405
- const state = { oauth1: void 0 };
406
- const auth = buildAuthProvider({
407
- httpClient,
408
- state,
409
- tokenStore,
410
- logger,
411
- fetchFn
412
- });
413
- return { auth, httpClient };
533
+ };
414
534
  };
415
535
 
416
536
  // src/adapters/mappers/workout-summary.mapper.ts
@@ -432,6 +552,26 @@ var garminPushResponseSchema = z.object({
432
552
  workoutId: z.number().or(z.string()),
433
553
  workoutName: z.string().optional()
434
554
  });
555
+ var pullWorkout = async (workoutId, httpClient, garminReader, log) => {
556
+ try {
557
+ log.info(`Pulling workout ${workoutId} from Garmin Connect`);
558
+ const raw = await httpClient.get(
559
+ `${WORKOUT_URL}/workout/${workoutId}`
560
+ );
561
+ const gcnJson = JSON.stringify(raw);
562
+ return await fromText(gcnJson, garminReader, log);
563
+ } catch (error) {
564
+ throw createServiceApiError("Failed to pull workout", void 0, error);
565
+ }
566
+ };
567
+ var removeWorkout = async (workoutId, httpClient, log) => {
568
+ try {
569
+ log.info(`Removing workout ${workoutId} from Garmin Connect`);
570
+ await httpClient.del(`${WORKOUT_URL}/workout/${workoutId}`);
571
+ } catch (error) {
572
+ throw createServiceApiError("Failed to remove workout", void 0, error);
573
+ }
574
+ };
435
575
 
436
576
  // src/adapters/client/garmin-workout-service.ts
437
577
  var pushWorkout = async (krd, httpClient, garminWriter, log) => {
@@ -474,17 +614,38 @@ var listWorkouts = async (httpClient, log, options) => {
474
614
  var createGarminWorkoutService = (httpClient, logger) => {
475
615
  const log = logger ?? createConsoleLogger();
476
616
  const garminWriter = createGarminWriter(log);
617
+ const garminReader = createGarminReader(log);
477
618
  return {
478
619
  push: (krd) => pushWorkout(krd, httpClient, garminWriter, log),
479
- list: (opts) => listWorkouts(httpClient, log, opts)
620
+ pull: (id) => pullWorkout(id, httpClient, garminReader, log),
621
+ list: (opts) => listWorkouts(httpClient, log, opts),
622
+ remove: (id) => removeWorkout(id, httpClient, log)
480
623
  };
481
624
  };
482
625
 
483
626
  // src/adapters/client/garmin-connect-client.ts
484
627
  var createGarminConnectClient = (options) => {
485
- const { auth, httpClient } = createGarminAuthProvider(options);
486
- const service = createGarminWorkoutService(httpClient, options?.logger);
487
- return { auth, service };
628
+ const logger = options?.logger ?? createConsoleLogger();
629
+ const rawFetchFn = options?.fetchFn ?? createCookieFetch();
630
+ const retryFetchFn = options?.retry ? withRetry(rawFetchFn, { ...options.retry, logger }) : rawFetchFn;
631
+ const refreshFn = buildRefreshFn(rawFetchFn, logger);
632
+ const tokenManager = createTokenManager({
633
+ refreshFn,
634
+ logger,
635
+ tokenStore: options?.tokenStore
636
+ });
637
+ const auth = createGarminAuthProvider({
638
+ tokenManager,
639
+ logger,
640
+ fetchFn: rawFetchFn
641
+ });
642
+ const httpClient = createGarminHttpClient(tokenManager, retryFetchFn, logger);
643
+ const service = createGarminWorkoutService(httpClient, logger);
644
+ return {
645
+ auth,
646
+ service,
647
+ init: () => tokenManager.init()
648
+ };
488
649
  };
489
650
  var DEFAULT_PATH = join(homedir(), ".kaiord", "garmin-tokens.json");
490
651
  var createFileTokenStore = (filePath = DEFAULT_PATH) => ({
@@ -525,6 +686,6 @@ var createMemoryTokenStore = () => {
525
686
  };
526
687
  };
527
688
 
528
- export { createCookieFetch, createFileTokenStore, createGarminAuthProvider, createGarminConnectClient, createMemoryTokenStore };
689
+ export { createCookieFetch, createFileTokenStore, createGarminAuthProvider, createGarminConnectClient, createMemoryTokenStore, createTokenManager };
529
690
  //# sourceMappingURL=index.js.map
530
691
  //# sourceMappingURL=index.js.map