@nativesquare/soma 0.5.0 → 0.7.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.
Files changed (54) hide show
  1. package/dist/client/index.d.ts +151 -53
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js +162 -69
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/component/_generated/component.d.ts +130 -17
  6. package/dist/component/_generated/component.d.ts.map +1 -1
  7. package/dist/component/garmin.d.ts +61 -43
  8. package/dist/component/garmin.d.ts.map +1 -1
  9. package/dist/component/garmin.js +208 -122
  10. package/dist/component/garmin.js.map +1 -1
  11. package/dist/component/public.d.ts +363 -0
  12. package/dist/component/public.d.ts.map +1 -1
  13. package/dist/component/public.js +124 -0
  14. package/dist/component/public.js.map +1 -1
  15. package/dist/component/schema.d.ts +7 -9
  16. package/dist/component/schema.d.ts.map +1 -1
  17. package/dist/component/schema.js +9 -10
  18. package/dist/component/schema.js.map +1 -1
  19. package/dist/component/strava.d.ts +0 -1
  20. package/dist/component/strava.d.ts.map +1 -1
  21. package/dist/component/strava.js +0 -1
  22. package/dist/component/strava.js.map +1 -1
  23. package/dist/component/validators/enums.d.ts +1 -1
  24. package/dist/garmin/auth.d.ts +55 -46
  25. package/dist/garmin/auth.d.ts.map +1 -1
  26. package/dist/garmin/auth.js +82 -122
  27. package/dist/garmin/auth.js.map +1 -1
  28. package/dist/garmin/client.d.ts +64 -17
  29. package/dist/garmin/client.d.ts.map +1 -1
  30. package/dist/garmin/client.js +143 -29
  31. package/dist/garmin/client.js.map +1 -1
  32. package/dist/garmin/index.d.ts +3 -3
  33. package/dist/garmin/index.d.ts.map +1 -1
  34. package/dist/garmin/index.js +4 -4
  35. package/dist/garmin/index.js.map +1 -1
  36. package/dist/garmin/plannedWorkout.d.ts +12 -0
  37. package/dist/garmin/plannedWorkout.d.ts.map +1 -0
  38. package/dist/garmin/plannedWorkout.js +267 -0
  39. package/dist/garmin/plannedWorkout.js.map +1 -0
  40. package/dist/garmin/types.d.ts +78 -6
  41. package/dist/garmin/types.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/client/index.ts +236 -85
  44. package/src/component/_generated/component.ts +155 -17
  45. package/src/component/garmin.ts +258 -124
  46. package/src/component/public.ts +135 -0
  47. package/src/component/schema.ts +9 -10
  48. package/src/component/strava.ts +0 -1
  49. package/src/garmin/auth.test.ts +71 -96
  50. package/src/garmin/auth.ts +129 -193
  51. package/src/garmin/client.ts +197 -51
  52. package/src/garmin/index.ts +13 -14
  53. package/src/garmin/plannedWorkout.ts +333 -0
  54. package/src/garmin/types.ts +149 -7
@@ -8,6 +8,7 @@ import { dailyValidator } from "./validators/daily.js";
8
8
  import { sleepValidator } from "./validators/sleep.js";
9
9
  import { menstruationValidator } from "./validators/menstruation.js";
10
10
  import { nutritionValidator } from "./validators/nutrition.js";
11
+ import { plannedWorkoutValidator } from "./validators/plannedWorkout.js";
11
12
 
12
13
  // ─── Return Validators ──────────────────────────────────────────────────────
13
14
 
@@ -776,6 +777,140 @@ export const paginateMenstruation = query({
776
777
  },
777
778
  });
778
779
 
780
+ // ── Planned Workouts ────────────────────────────────────────────────────────
781
+
782
+ /**
783
+ * Ingest a planned workout record.
784
+ *
785
+ * Upserts by `connectionId + metadata.id` when an id is present.
786
+ * Falls back to insert if no id is provided.
787
+ */
788
+ export const ingestPlannedWorkout = mutation({
789
+ args: plannedWorkoutValidator,
790
+ returns: v.id("plannedWorkouts"),
791
+ handler: async (ctx, args) => {
792
+ const metadataId = args.metadata.id;
793
+ if (metadataId) {
794
+ const results = await ctx.db
795
+ .query("plannedWorkouts")
796
+ .withIndex("by_connectionId", (q) =>
797
+ q.eq("connectionId", args.connectionId),
798
+ )
799
+ .collect();
800
+ const existing = results.find((r) => r.metadata.id === metadataId);
801
+
802
+ if (existing) {
803
+ await ctx.db.patch(existing._id, args);
804
+ return existing._id;
805
+ }
806
+ }
807
+ return await ctx.db.insert("plannedWorkouts", args);
808
+ },
809
+ });
810
+
811
+ /**
812
+ * List planned workout records for a user, optionally filtered by planned date range.
813
+ *
814
+ * @param args.userId - The host app's user identifier
815
+ * @param args.startDate - Optional lower bound (inclusive) on metadata.planned_date (YYYY-MM-DD)
816
+ * @param args.endDate - Optional upper bound (inclusive) on metadata.planned_date (YYYY-MM-DD)
817
+ * @param args.order - Sort order: "asc" or "desc" (default: "desc")
818
+ * @param args.limit - Optional max number of results to return
819
+ */
820
+ export const listPlannedWorkouts = query({
821
+ args: {
822
+ userId: v.string(),
823
+ startDate: v.optional(v.string()),
824
+ endDate: v.optional(v.string()),
825
+ order: v.optional(v.union(v.literal("asc"), v.literal("desc"))),
826
+ limit: v.optional(v.number()),
827
+ },
828
+ handler: async (ctx, args) => {
829
+ const q = ctx.db
830
+ .query("plannedWorkouts")
831
+ .withIndex("by_userId_plannedDate", (q) => {
832
+ const base = q.eq("userId", args.userId);
833
+ if (args.startDate !== undefined && args.endDate !== undefined) {
834
+ return base
835
+ .gte("metadata.planned_date", args.startDate)
836
+ .lte("metadata.planned_date", args.endDate);
837
+ }
838
+ if (args.startDate !== undefined) {
839
+ return base.gte("metadata.planned_date", args.startDate);
840
+ }
841
+ if (args.endDate !== undefined) {
842
+ return base.lte("metadata.planned_date", args.endDate);
843
+ }
844
+ return base;
845
+ })
846
+ .order(args.order ?? "desc");
847
+ return args.limit ? await q.take(args.limit) : await q.collect();
848
+ },
849
+ });
850
+
851
+ /**
852
+ * Paginate planned workout records for a user, optionally filtered by planned date range.
853
+ *
854
+ * Returns `{ page, isDone, continueCursor }` for cursor-based pagination.
855
+ */
856
+ export const paginatePlannedWorkouts = query({
857
+ args: {
858
+ userId: v.string(),
859
+ startDate: v.optional(v.string()),
860
+ endDate: v.optional(v.string()),
861
+ paginationOpts: paginationOptsValidator,
862
+ },
863
+ handler: async (ctx, args) => {
864
+ return await ctx.db
865
+ .query("plannedWorkouts")
866
+ .withIndex("by_userId_plannedDate", (q) => {
867
+ const base = q.eq("userId", args.userId);
868
+ if (args.startDate !== undefined && args.endDate !== undefined) {
869
+ return base
870
+ .gte("metadata.planned_date", args.startDate)
871
+ .lte("metadata.planned_date", args.endDate);
872
+ }
873
+ if (args.startDate !== undefined) {
874
+ return base.gte("metadata.planned_date", args.startDate);
875
+ }
876
+ if (args.endDate !== undefined) {
877
+ return base.lte("metadata.planned_date", args.endDate);
878
+ }
879
+ return base;
880
+ })
881
+ .order("desc")
882
+ .paginate(args.paginationOpts);
883
+ },
884
+ });
885
+
886
+ /**
887
+ * Delete a planned workout by document ID.
888
+ */
889
+ export const deletePlannedWorkout = mutation({
890
+ args: { plannedWorkoutId: v.id("plannedWorkouts") },
891
+ returns: v.null(),
892
+ handler: async (ctx, args) => {
893
+ const existing = await ctx.db.get(args.plannedWorkoutId);
894
+ if (!existing) {
895
+ throw new Error(
896
+ `Planned workout "${args.plannedWorkoutId}" not found`,
897
+ );
898
+ }
899
+ await ctx.db.delete(existing._id);
900
+ return null;
901
+ },
902
+ });
903
+
904
+ /**
905
+ * Get a single planned workout by document ID.
906
+ */
907
+ export const getPlannedWorkout = query({
908
+ args: { plannedWorkoutId: v.id("plannedWorkouts") },
909
+ handler: async (ctx, args) => {
910
+ return await ctx.db.get(args.plannedWorkoutId);
911
+ },
912
+ });
913
+
779
914
  // ── Athletes ────────────────────────────────────────────────────────────────
780
915
 
781
916
  /**
@@ -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()), // OAuth 2.0 providers (Strava)
125
- tokenSecret: v.optional(v.string()), // OAuth 1.0a providers (Garmin)
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 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.
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
- oauthToken: v.string(),
136
- tokenSecret: v.string(),
134
+ state: v.string(),
135
+ codeVerifier: v.string(),
137
136
  userId: v.string(),
138
137
  createdAt: v.number(),
139
- }).index("by_oauthToken", ["oauthToken"]),
138
+ }).index("by_state", ["state"]),
140
139
  });
@@ -75,7 +75,6 @@ export const getTokens = internalQuery({
75
75
  connectionId: v.id("connections"),
76
76
  accessToken: v.string(),
77
77
  refreshToken: v.optional(v.string()),
78
- tokenSecret: v.optional(v.string()),
79
78
  expiresAt: v.optional(v.number()),
80
79
  }),
81
80
  v.null(),
@@ -1,128 +1,103 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
- buildOAuthSignature,
4
- buildOAuthHeader,
5
- percentEncode,
6
- generateNonce,
7
- getTimestamp,
3
+ generateCodeVerifier,
4
+ generateCodeChallenge,
5
+ generateState,
6
+ buildAuthUrl,
8
7
  } from "./auth.js";
9
8
 
10
- describe("percentEncode", () => {
11
- it("encodes special characters per RFC 3986", () => {
12
- expect(percentEncode("hello world")).toBe("hello%20world");
13
- expect(percentEncode("a=b&c=d")).toBe("a%3Db%26c%3Dd");
14
- expect(percentEncode("test!")).toBe("test%21");
15
- expect(percentEncode("100%")).toBe("100%25");
9
+ describe("generateCodeVerifier", () => {
10
+ it("returns a 64-character string by default", () => {
11
+ const verifier = generateCodeVerifier();
12
+ expect(verifier).toHaveLength(64);
16
13
  });
17
14
 
18
- it("encodes characters that encodeURIComponent misses", () => {
19
- expect(percentEncode("a'b")).toBe("a%27b");
20
- expect(percentEncode("a(b)")).toBe("a%28b%29");
21
- expect(percentEncode("a*b")).toBe("a%2Ab");
15
+ it("only contains valid PKCE characters", () => {
16
+ const verifier = generateCodeVerifier();
17
+ expect(verifier).toMatch(/^[A-Za-z0-9\-._~]+$/);
22
18
  });
23
19
 
24
- it("does not encode unreserved characters", () => {
25
- expect(percentEncode("abcXYZ123")).toBe("abcXYZ123");
26
- expect(percentEncode("-._~")).toBe("-._~");
20
+ it("respects custom length", () => {
21
+ const verifier = generateCodeVerifier(128);
22
+ expect(verifier).toHaveLength(128);
23
+ });
24
+
25
+ it("generates unique values", () => {
26
+ const v1 = generateCodeVerifier();
27
+ const v2 = generateCodeVerifier();
28
+ expect(v1).not.toBe(v2);
27
29
  });
28
30
  });
29
31
 
30
- describe("generateNonce", () => {
31
- it("returns a 32-character hex string", () => {
32
- const nonce = generateNonce();
33
- expect(nonce).toHaveLength(32);
34
- expect(nonce).toMatch(/^[0-9a-f]+$/);
32
+ describe("generateState", () => {
33
+ it("returns a 64-character hex string", () => {
34
+ const state = generateState();
35
+ expect(state).toHaveLength(64);
36
+ expect(state).toMatch(/^[0-9a-f]+$/);
35
37
  });
36
38
 
37
39
  it("generates unique values", () => {
38
- const nonce1 = generateNonce();
39
- const nonce2 = generateNonce();
40
- expect(nonce1).not.toBe(nonce2);
40
+ const s1 = generateState();
41
+ const s2 = generateState();
42
+ expect(s1).not.toBe(s2);
41
43
  });
42
44
  });
43
45
 
44
- describe("getTimestamp", () => {
45
- it("returns a Unix timestamp string", () => {
46
- const ts = getTimestamp();
47
- const parsed = parseInt(ts, 10);
48
- expect(parsed).toBeGreaterThan(1700000000);
49
- expect(String(parsed)).toBe(ts);
46
+ describe("generateCodeChallenge", () => {
47
+ it("produces a base64url-encoded string", async () => {
48
+ const verifier = generateCodeVerifier();
49
+ const challenge = await generateCodeChallenge(verifier);
50
+ expect(challenge).toMatch(/^[A-Za-z0-9\-_]+$/);
51
+ expect(challenge).not.toContain("+");
52
+ expect(challenge).not.toContain("/");
53
+ expect(challenge).not.toContain("=");
50
54
  });
51
- });
52
-
53
- describe("buildOAuthSignature", () => {
54
- it("produces a valid HMAC-SHA1 signature", async () => {
55
- const signature = await buildOAuthSignature(
56
- "GET",
57
- "https://api.example.com/resource",
58
- {
59
- oauth_consumer_key: "consumer_key",
60
- oauth_nonce: "kllo9940pd9333jh",
61
- oauth_signature_method: "HMAC-SHA1",
62
- oauth_timestamp: "1191242096",
63
- oauth_version: "1.0",
64
- },
65
- "consumer_secret",
66
- "",
67
- );
68
55
 
69
- expect(signature).toBeTruthy();
70
- expect(typeof signature).toBe("string");
71
- expect(signature).toMatch(/^[A-Za-z0-9+/]+=*$/);
56
+ it("produces different challenges for different verifiers", async () => {
57
+ const c1 = await generateCodeChallenge("verifier_one_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
58
+ const c2 = await generateCodeChallenge("verifier_two_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
59
+ expect(c1).not.toBe(c2);
72
60
  });
73
61
 
74
- it("includes token secret in signing key when provided", async () => {
75
- const sig1 = await buildOAuthSignature(
76
- "POST",
77
- "https://api.example.com/resource",
78
- { oauth_consumer_key: "key", oauth_nonce: "abc", oauth_timestamp: "123" },
79
- "consumer_secret",
80
- "",
81
- );
62
+ it("produces consistent challenges for the same verifier", async () => {
63
+ const verifier = "consistent_verifier_test_aaaaaaaaaaaaaaaaaa";
64
+ const c1 = await generateCodeChallenge(verifier);
65
+ const c2 = await generateCodeChallenge(verifier);
66
+ expect(c1).toBe(c2);
67
+ });
68
+ });
82
69
 
83
- const sig2 = await buildOAuthSignature(
84
- "POST",
85
- "https://api.example.com/resource",
86
- { oauth_consumer_key: "key", oauth_nonce: "abc", oauth_timestamp: "123" },
87
- "consumer_secret",
88
- "token_secret",
89
- );
70
+ describe("buildAuthUrl", () => {
71
+ it("builds a valid authorization URL with required params", () => {
72
+ const url = buildAuthUrl({
73
+ clientId: "test-client-id",
74
+ codeChallenge: "test-challenge",
75
+ });
90
76
 
91
- expect(sig1).not.toBe(sig2);
77
+ expect(url).toContain("https://connect.garmin.com/oauth2Confirm?");
78
+ expect(url).toContain("client_id=test-client-id");
79
+ expect(url).toContain("response_type=code");
80
+ expect(url).toContain("code_challenge=test-challenge");
81
+ expect(url).toContain("code_challenge_method=S256");
92
82
  });
93
83
 
94
- it("sorts parameters alphabetically", async () => {
95
- const sig1 = await buildOAuthSignature(
96
- "GET",
97
- "https://api.example.com/resource",
98
- { z_param: "last", a_param: "first", m_param: "middle" },
99
- "secret",
100
- );
101
- const sig2 = await buildOAuthSignature(
102
- "GET",
103
- "https://api.example.com/resource",
104
- { a_param: "first", m_param: "middle", z_param: "last" },
105
- "secret",
106
- );
84
+ it("includes optional redirect_uri", () => {
85
+ const url = buildAuthUrl({
86
+ clientId: "test-client-id",
87
+ codeChallenge: "test-challenge",
88
+ redirectUri: "https://example.com/callback",
89
+ });
107
90
 
108
- expect(sig1).toBe(sig2);
91
+ expect(url).toContain("redirect_uri=https%3A%2F%2Fexample.com%2Fcallback");
109
92
  });
110
- });
111
93
 
112
- describe("buildOAuthHeader", () => {
113
- it("builds a valid OAuth Authorization header", () => {
114
- const header = buildOAuthHeader({
115
- oauth_consumer_key: "my_key",
116
- oauth_nonce: "abc123",
117
- oauth_signature: "sig%3D",
118
- oauth_signature_method: "HMAC-SHA1",
119
- oauth_timestamp: "1234567890",
120
- oauth_version: "1.0",
94
+ it("includes optional state", () => {
95
+ const url = buildAuthUrl({
96
+ clientId: "test-client-id",
97
+ codeChallenge: "test-challenge",
98
+ state: "my-state-value",
121
99
  });
122
100
 
123
- expect(header).toMatch(/^OAuth /);
124
- expect(header).toContain('oauth_consumer_key="my_key"');
125
- expect(header).toContain('oauth_nonce="abc123"');
126
- expect(header).toContain('oauth_version="1.0"');
101
+ expect(url).toContain("state=my-state-value");
127
102
  });
128
103
  });