@clipin/convex-wearables 0.0.2 → 0.1.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/README.md +395 -0
- package/dist/client/index.d.ts +47 -6
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +30 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/types.d.ts +83 -0
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client/types.js.map +1 -1
- package/dist/component/_generated/component.d.ts +50 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/backfillJobs.d.ts +11 -11
- package/dist/component/connections.d.ts +9 -9
- package/dist/component/connections.d.ts.map +1 -1
- package/dist/component/connections.js +2 -0
- package/dist/component/connections.js.map +1 -1
- package/dist/component/dataPoints.d.ts +153 -39
- package/dist/component/dataPoints.d.ts.map +1 -1
- package/dist/component/dataPoints.js +1048 -139
- package/dist/component/dataPoints.js.map +1 -1
- package/dist/component/events.d.ts +13 -13
- package/dist/component/garminBackfill.d.ts +2 -2
- package/dist/component/garminWebhooks.d.ts +2 -2
- package/dist/component/garminWebhooks.d.ts.map +1 -1
- package/dist/component/garminWebhooks.js +2 -0
- package/dist/component/garminWebhooks.js.map +1 -1
- package/dist/component/lifecycle.d.ts +1 -1
- package/dist/component/lifecycle.d.ts.map +1 -1
- package/dist/component/lifecycle.js +39 -1
- package/dist/component/lifecycle.js.map +1 -1
- package/dist/component/oauthStates.d.ts +3 -3
- package/dist/component/schema.d.ts +192 -28
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +89 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sdkPush.d.ts +11 -11
- package/dist/component/summaries.d.ts +4 -4
- package/dist/component/syncJobs.d.ts +23 -23
- package/dist/component/syncWorkflow.d.ts +2 -2
- package/dist/component/timeSeriesPolicyUtils.d.ts +97 -0
- package/dist/component/timeSeriesPolicyUtils.d.ts.map +1 -0
- package/dist/component/timeSeriesPolicyUtils.js +163 -0
- package/dist/component/timeSeriesPolicyUtils.js.map +1 -0
- package/dist/test.d.ts +581 -0
- package/dist/test.d.ts.map +1 -0
- package/dist/test.js +17 -0
- package/dist/test.js.map +1 -0
- package/package.json +12 -2
- package/src/client/_generated/_ignore.ts +2 -0
- package/src/client/index.test.ts +149 -0
- package/src/client/index.ts +859 -0
- package/src/client/types.ts +632 -0
- package/src/component/_generated/_ignore.ts +2 -0
- package/src/component/_generated/api.ts +16 -0
- package/src/component/_generated/component.ts +74 -0
- package/src/component/_generated/dataModel.ts +40 -0
- package/src/component/_generated/server.ts +48 -0
- package/src/component/backfillJobs.test.ts +47 -0
- package/src/component/backfillJobs.ts +245 -0
- package/src/component/connections.test.ts +297 -0
- package/src/component/connections.ts +329 -0
- package/src/component/convex.config.ts +7 -0
- package/src/component/dataPoints.test.ts +827 -0
- package/src/component/dataPoints.ts +1676 -0
- package/src/component/dataSources.test.ts +247 -0
- package/src/component/dataSources.ts +109 -0
- package/src/component/events.test.ts +380 -0
- package/src/component/events.ts +288 -0
- package/src/component/garminBackfill.ts +343 -0
- package/src/component/garminWebhooks.test.ts +609 -0
- package/src/component/garminWebhooks.ts +656 -0
- package/src/component/httpHandlers.ts +153 -0
- package/src/component/lifecycle.test.ts +179 -0
- package/src/component/lifecycle.ts +128 -0
- package/src/component/menstrualCycles.ts +124 -0
- package/src/component/oauthActions.ts +261 -0
- package/src/component/oauthStates.test.ts +170 -0
- package/src/component/oauthStates.ts +85 -0
- package/src/component/providerSettings.ts +66 -0
- package/src/component/providers/additionalProviders.test.ts +401 -0
- package/src/component/providers/garmin.ts +1169 -0
- package/src/component/providers/oauth.test.ts +174 -0
- package/src/component/providers/oauth.ts +246 -0
- package/src/component/providers/polar.ts +220 -0
- package/src/component/providers/registry.ts +37 -0
- package/src/component/providers/strava.test.ts +195 -0
- package/src/component/providers/strava.ts +253 -0
- package/src/component/providers/suunto.ts +592 -0
- package/src/component/providers/types.ts +189 -0
- package/src/component/providers/whoop.ts +600 -0
- package/src/component/schema.ts +445 -0
- package/src/component/sdkPush.test.ts +367 -0
- package/src/component/sdkPush.ts +440 -0
- package/src/component/summaries.test.ts +201 -0
- package/src/component/summaries.ts +143 -0
- package/src/component/syncJobs.test.ts +254 -0
- package/src/component/syncJobs.ts +140 -0
- package/src/component/syncWorkflow.test.ts +87 -0
- package/src/component/syncWorkflow.ts +739 -0
- package/src/component/test.setup.ts +6 -0
- package/src/component/timeSeriesPolicyUtils.ts +243 -0
- package/src/component/workflowManager.ts +19 -0
- package/src/test.ts +25 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth flow Convex actions.
|
|
3
|
+
*
|
|
4
|
+
* Actions (not mutations) because they make external HTTP calls to
|
|
5
|
+
* provider token endpoints and APIs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { v } from "convex/values";
|
|
9
|
+
import { api, internal } from "./_generated/api";
|
|
10
|
+
import { action, internalAction } from "./_generated/server";
|
|
11
|
+
import {
|
|
12
|
+
buildAuthorizationUrl,
|
|
13
|
+
exchangeCodeForTokens,
|
|
14
|
+
generateCodeChallenge,
|
|
15
|
+
generateRandomString,
|
|
16
|
+
refreshAccessToken,
|
|
17
|
+
} from "./providers/oauth";
|
|
18
|
+
import { getProvider } from "./providers/registry";
|
|
19
|
+
import type { ProviderCredentials } from "./providers/types";
|
|
20
|
+
import { providerName } from "./schema";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Generate authorization URL
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Generate an OAuth authorization URL for a provider.
|
|
28
|
+
*
|
|
29
|
+
* Stores the state token in the oauthStates table and returns the URL
|
|
30
|
+
* the client should redirect the user to.
|
|
31
|
+
*/
|
|
32
|
+
export const generateAuthUrl = action({
|
|
33
|
+
args: {
|
|
34
|
+
userId: v.string(),
|
|
35
|
+
provider: providerName,
|
|
36
|
+
clientId: v.string(),
|
|
37
|
+
clientSecret: v.string(),
|
|
38
|
+
subscriptionKey: v.optional(v.string()),
|
|
39
|
+
redirectUri: v.string(),
|
|
40
|
+
},
|
|
41
|
+
returns: v.string(),
|
|
42
|
+
handler: async (ctx, args) => {
|
|
43
|
+
await ctx.runMutation(internal.providerSettings.upsertCredentials, {
|
|
44
|
+
provider: args.provider,
|
|
45
|
+
clientId: args.clientId,
|
|
46
|
+
clientSecret: args.clientSecret,
|
|
47
|
+
subscriptionKey: args.subscriptionKey,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const providerDef = getProvider(args.provider);
|
|
51
|
+
if (!providerDef) {
|
|
52
|
+
throw new Error(`Provider "${args.provider}" is not implemented`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const credentials: ProviderCredentials = {
|
|
56
|
+
clientId: args.clientId,
|
|
57
|
+
clientSecret: args.clientSecret,
|
|
58
|
+
subscriptionKey: args.subscriptionKey,
|
|
59
|
+
};
|
|
60
|
+
const config = providerDef.oauthConfig(credentials);
|
|
61
|
+
|
|
62
|
+
// Generate state token
|
|
63
|
+
const state = generateRandomString(32);
|
|
64
|
+
|
|
65
|
+
// PKCE if the provider requires it
|
|
66
|
+
let codeVerifier: string | undefined;
|
|
67
|
+
let codeChallenge: string | undefined;
|
|
68
|
+
if (config.usePkce) {
|
|
69
|
+
codeVerifier = generateRandomString(64);
|
|
70
|
+
codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Store state in the database for validation during callback
|
|
74
|
+
await ctx.runMutation(internal.oauthStates.store, {
|
|
75
|
+
state,
|
|
76
|
+
userId: args.userId,
|
|
77
|
+
provider: args.provider,
|
|
78
|
+
codeVerifier,
|
|
79
|
+
redirectUri: args.redirectUri,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Build the authorization URL
|
|
83
|
+
const url = buildAuthorizationUrl({
|
|
84
|
+
config,
|
|
85
|
+
redirectUri: args.redirectUri,
|
|
86
|
+
state,
|
|
87
|
+
codeChallenge,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return url;
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Handle OAuth callback (exchange code for tokens)
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Handle the OAuth callback. Consumes the state token, exchanges the
|
|
100
|
+
* authorization code for access/refresh tokens, fetches the user's
|
|
101
|
+
* provider profile, and creates/updates the connection.
|
|
102
|
+
*/
|
|
103
|
+
export const handleCallback = action({
|
|
104
|
+
args: {
|
|
105
|
+
state: v.string(),
|
|
106
|
+
code: v.string(),
|
|
107
|
+
clientId: v.string(),
|
|
108
|
+
clientSecret: v.string(),
|
|
109
|
+
subscriptionKey: v.optional(v.string()),
|
|
110
|
+
},
|
|
111
|
+
returns: v.object({
|
|
112
|
+
provider: v.string(),
|
|
113
|
+
userId: v.string(),
|
|
114
|
+
connectionId: v.string(),
|
|
115
|
+
}),
|
|
116
|
+
handler: async (ctx, args) => {
|
|
117
|
+
// Consume the state token
|
|
118
|
+
const oauthState = await ctx.runMutation(internal.oauthStates.consume, {
|
|
119
|
+
state: args.state,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (!oauthState) {
|
|
123
|
+
throw new Error("Invalid or expired OAuth state");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const providerDef = getProvider(oauthState.provider);
|
|
127
|
+
if (!providerDef) {
|
|
128
|
+
throw new Error(`Provider "${oauthState.provider}" is not implemented`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await ctx.runMutation(internal.providerSettings.upsertCredentials, {
|
|
132
|
+
provider: oauthState.provider,
|
|
133
|
+
clientId: args.clientId,
|
|
134
|
+
clientSecret: args.clientSecret,
|
|
135
|
+
subscriptionKey: args.subscriptionKey,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const credentials: ProviderCredentials = {
|
|
139
|
+
clientId: args.clientId,
|
|
140
|
+
clientSecret: args.clientSecret,
|
|
141
|
+
subscriptionKey: args.subscriptionKey,
|
|
142
|
+
};
|
|
143
|
+
const config = providerDef.oauthConfig(credentials);
|
|
144
|
+
|
|
145
|
+
// Exchange code for tokens
|
|
146
|
+
const tokenResponse = await exchangeCodeForTokens(
|
|
147
|
+
config,
|
|
148
|
+
args.code,
|
|
149
|
+
oauthState.redirectUri ?? "",
|
|
150
|
+
oauthState.codeVerifier,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// Fetch user info from the provider
|
|
154
|
+
if (providerDef.postConnect) {
|
|
155
|
+
await providerDef.postConnect(
|
|
156
|
+
tokenResponse.access_token,
|
|
157
|
+
tokenResponse,
|
|
158
|
+
oauthState.userId,
|
|
159
|
+
credentials,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const userInfo = await providerDef.getUserInfo(
|
|
164
|
+
tokenResponse.access_token,
|
|
165
|
+
tokenResponse,
|
|
166
|
+
oauthState.userId,
|
|
167
|
+
credentials,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// Calculate token expiry
|
|
171
|
+
const tokenExpiresAt = tokenResponse.expires_in
|
|
172
|
+
? Date.now() + tokenResponse.expires_in * 1000
|
|
173
|
+
: undefined;
|
|
174
|
+
|
|
175
|
+
// Create or update the connection
|
|
176
|
+
const connectionId = await ctx.runMutation(internal.connections.createConnection, {
|
|
177
|
+
userId: oauthState.userId,
|
|
178
|
+
provider: oauthState.provider,
|
|
179
|
+
providerUserId: userInfo.providerUserId ?? undefined,
|
|
180
|
+
providerUsername: userInfo.username ?? undefined,
|
|
181
|
+
accessToken: tokenResponse.access_token,
|
|
182
|
+
refreshToken: tokenResponse.refresh_token,
|
|
183
|
+
tokenExpiresAt,
|
|
184
|
+
scope: tokenResponse.scope,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Create a data source for this connection
|
|
188
|
+
await ctx.runMutation(api.dataSources.getOrCreate, {
|
|
189
|
+
userId: oauthState.userId,
|
|
190
|
+
provider: oauthState.provider,
|
|
191
|
+
connectionId,
|
|
192
|
+
source: oauthState.provider,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
provider: oauthState.provider,
|
|
197
|
+
userId: oauthState.userId,
|
|
198
|
+
connectionId: String(connectionId),
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Ensure a connection has a valid (non-expired) access token.
|
|
205
|
+
* If expired, refreshes the token and updates the connection.
|
|
206
|
+
* Returns the valid access token.
|
|
207
|
+
*/
|
|
208
|
+
export const ensureValidToken = internalAction({
|
|
209
|
+
args: {
|
|
210
|
+
connectionId: v.id("connections"),
|
|
211
|
+
provider: providerName,
|
|
212
|
+
accessToken: v.string(),
|
|
213
|
+
refreshToken: v.optional(v.string()),
|
|
214
|
+
tokenExpiresAt: v.optional(v.number()),
|
|
215
|
+
clientId: v.string(),
|
|
216
|
+
clientSecret: v.string(),
|
|
217
|
+
subscriptionKey: v.optional(v.string()),
|
|
218
|
+
},
|
|
219
|
+
returns: v.string(), // valid access token
|
|
220
|
+
handler: async (ctx, args) => {
|
|
221
|
+
// Check if token is still valid (with 5-minute buffer)
|
|
222
|
+
const bufferMs = 5 * 60 * 1000;
|
|
223
|
+
if (args.tokenExpiresAt && args.tokenExpiresAt - bufferMs > Date.now()) {
|
|
224
|
+
return args.accessToken;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Token is expired or about to expire — refresh it
|
|
228
|
+
if (!args.refreshToken) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`Token expired for ${args.provider} connection and no refresh token available`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const providerDef = getProvider(args.provider);
|
|
235
|
+
if (!providerDef) {
|
|
236
|
+
throw new Error(`Provider "${args.provider}" is not implemented`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const credentials: ProviderCredentials = {
|
|
240
|
+
clientId: args.clientId,
|
|
241
|
+
clientSecret: args.clientSecret,
|
|
242
|
+
subscriptionKey: args.subscriptionKey,
|
|
243
|
+
};
|
|
244
|
+
const config = providerDef.oauthConfig(credentials);
|
|
245
|
+
const tokenResponse = await refreshAccessToken(config, args.refreshToken);
|
|
246
|
+
|
|
247
|
+
const newExpiresAt = tokenResponse.expires_in
|
|
248
|
+
? Date.now() + tokenResponse.expires_in * 1000
|
|
249
|
+
: undefined;
|
|
250
|
+
|
|
251
|
+
// Update the connection with new tokens
|
|
252
|
+
await ctx.runMutation(internal.connections.updateTokens, {
|
|
253
|
+
connectionId: args.connectionId,
|
|
254
|
+
accessToken: tokenResponse.access_token,
|
|
255
|
+
refreshToken: tokenResponse.refresh_token ?? args.refreshToken,
|
|
256
|
+
tokenExpiresAt: newExpiresAt,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
return tokenResponse.access_token;
|
|
260
|
+
},
|
|
261
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { convexTest } from "convex-test";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import schema from "./schema";
|
|
4
|
+
import { modules } from "./test.setup";
|
|
5
|
+
|
|
6
|
+
describe("oauthStates", () => {
|
|
7
|
+
it("stores and retrieves a state by token", async () => {
|
|
8
|
+
const t = convexTest(schema, modules);
|
|
9
|
+
|
|
10
|
+
const id = await t.run(async (ctx) => {
|
|
11
|
+
return await ctx.db.insert("oauthStates", {
|
|
12
|
+
state: "random-state-abc",
|
|
13
|
+
userId: "user-1",
|
|
14
|
+
provider: "strava",
|
|
15
|
+
createdAt: Date.now(),
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const found = await t.run(async (ctx) => {
|
|
20
|
+
return await ctx.db
|
|
21
|
+
.query("oauthStates")
|
|
22
|
+
.withIndex("by_state", (idx) => idx.eq("state", "random-state-abc"))
|
|
23
|
+
.first();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
expect(found).not.toBeNull();
|
|
27
|
+
expect(found?._id).toBe(id);
|
|
28
|
+
expect(found?.userId).toBe("user-1");
|
|
29
|
+
expect(found?.provider).toBe("strava");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("stores PKCE code verifier and redirect URI", async () => {
|
|
33
|
+
const t = convexTest(schema, modules);
|
|
34
|
+
|
|
35
|
+
await t.run(async (ctx) => {
|
|
36
|
+
await ctx.db.insert("oauthStates", {
|
|
37
|
+
state: "pkce-state-123",
|
|
38
|
+
userId: "user-1",
|
|
39
|
+
provider: "suunto",
|
|
40
|
+
codeVerifier: "verifier-abc-xyz",
|
|
41
|
+
redirectUri: "https://example.com/callback",
|
|
42
|
+
createdAt: Date.now(),
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const found = await t.run(async (ctx) => {
|
|
47
|
+
return await ctx.db
|
|
48
|
+
.query("oauthStates")
|
|
49
|
+
.withIndex("by_state", (idx) => idx.eq("state", "pkce-state-123"))
|
|
50
|
+
.first();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(found?.codeVerifier).toBe("verifier-abc-xyz");
|
|
54
|
+
expect(found?.redirectUri).toBe("https://example.com/callback");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("consumes state (read and delete)", async () => {
|
|
58
|
+
const t = convexTest(schema, modules);
|
|
59
|
+
|
|
60
|
+
await t.run(async (ctx) => {
|
|
61
|
+
await ctx.db.insert("oauthStates", {
|
|
62
|
+
state: "consume-me",
|
|
63
|
+
userId: "user-1",
|
|
64
|
+
provider: "strava",
|
|
65
|
+
createdAt: Date.now(),
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Consume: read and delete
|
|
70
|
+
const consumed = await t.run(async (ctx) => {
|
|
71
|
+
const record = await ctx.db
|
|
72
|
+
.query("oauthStates")
|
|
73
|
+
.withIndex("by_state", (idx) => idx.eq("state", "consume-me"))
|
|
74
|
+
.first();
|
|
75
|
+
if (record) {
|
|
76
|
+
await ctx.db.delete(record._id);
|
|
77
|
+
}
|
|
78
|
+
return record;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(consumed).not.toBeNull();
|
|
82
|
+
expect(consumed?.provider).toBe("strava");
|
|
83
|
+
|
|
84
|
+
// Verify it's gone
|
|
85
|
+
const afterConsume = await t.run(async (ctx) => {
|
|
86
|
+
return await ctx.db
|
|
87
|
+
.query("oauthStates")
|
|
88
|
+
.withIndex("by_state", (idx) => idx.eq("state", "consume-me"))
|
|
89
|
+
.first();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(afterConsume).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns null when consuming non-existent state", async () => {
|
|
96
|
+
const t = convexTest(schema, modules);
|
|
97
|
+
|
|
98
|
+
const result = await t.run(async (ctx) => {
|
|
99
|
+
return await ctx.db
|
|
100
|
+
.query("oauthStates")
|
|
101
|
+
.withIndex("by_state", (idx) => idx.eq("state", "does-not-exist"))
|
|
102
|
+
.first();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(result).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("handles multiple states for same user (different providers)", async () => {
|
|
109
|
+
const t = convexTest(schema, modules);
|
|
110
|
+
|
|
111
|
+
await t.run(async (ctx) => {
|
|
112
|
+
await ctx.db.insert("oauthStates", {
|
|
113
|
+
state: "state-strava",
|
|
114
|
+
userId: "user-1",
|
|
115
|
+
provider: "strava",
|
|
116
|
+
createdAt: Date.now(),
|
|
117
|
+
});
|
|
118
|
+
await ctx.db.insert("oauthStates", {
|
|
119
|
+
state: "state-garmin",
|
|
120
|
+
userId: "user-1",
|
|
121
|
+
provider: "garmin",
|
|
122
|
+
createdAt: Date.now(),
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const strava = await t.run(async (ctx) => {
|
|
127
|
+
return await ctx.db
|
|
128
|
+
.query("oauthStates")
|
|
129
|
+
.withIndex("by_state", (idx) => idx.eq("state", "state-strava"))
|
|
130
|
+
.first();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const garmin = await t.run(async (ctx) => {
|
|
134
|
+
return await ctx.db
|
|
135
|
+
.query("oauthStates")
|
|
136
|
+
.withIndex("by_state", (idx) => idx.eq("state", "state-garmin"))
|
|
137
|
+
.first();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(strava?.provider).toBe("strava");
|
|
141
|
+
expect(garmin?.provider).toBe("garmin");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("cleanup deletes state by ID", async () => {
|
|
145
|
+
const t = convexTest(schema, modules);
|
|
146
|
+
|
|
147
|
+
const id = await t.run(async (ctx) => {
|
|
148
|
+
return await ctx.db.insert("oauthStates", {
|
|
149
|
+
state: "to-cleanup",
|
|
150
|
+
userId: "user-1",
|
|
151
|
+
provider: "whoop",
|
|
152
|
+
createdAt: Date.now(),
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Simulate scheduled cleanup
|
|
157
|
+
await t.run(async (ctx) => {
|
|
158
|
+
const record = await ctx.db.get(id);
|
|
159
|
+
if (record) {
|
|
160
|
+
await ctx.db.delete(id);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const after = await t.run(async (ctx) => {
|
|
165
|
+
return await ctx.db.get(id);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(after).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { internal } from "./_generated/api";
|
|
3
|
+
import { internalMutation, internalQuery } from "./_generated/server";
|
|
4
|
+
import { providerName } from "./schema";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Queries
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Look up an OAuth state by its state token.
|
|
12
|
+
*/
|
|
13
|
+
export const getByState = internalQuery({
|
|
14
|
+
args: { state: v.string() },
|
|
15
|
+
returns: v.any(),
|
|
16
|
+
handler: async (ctx, args) => {
|
|
17
|
+
return await ctx.db
|
|
18
|
+
.query("oauthStates")
|
|
19
|
+
.withIndex("by_state", (idx) => idx.eq("state", args.state))
|
|
20
|
+
.first();
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Mutations
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Store a new OAuth state. Schedules automatic cleanup after 15 minutes.
|
|
30
|
+
*/
|
|
31
|
+
export const store = internalMutation({
|
|
32
|
+
args: {
|
|
33
|
+
state: v.string(),
|
|
34
|
+
userId: v.string(),
|
|
35
|
+
provider: providerName,
|
|
36
|
+
codeVerifier: v.optional(v.string()),
|
|
37
|
+
redirectUri: v.optional(v.string()),
|
|
38
|
+
},
|
|
39
|
+
returns: v.id("oauthStates"),
|
|
40
|
+
handler: async (ctx, args) => {
|
|
41
|
+
const id = await ctx.db.insert("oauthStates", {
|
|
42
|
+
...args,
|
|
43
|
+
createdAt: Date.now(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Schedule cleanup in 15 minutes
|
|
47
|
+
await ctx.scheduler.runAfter(15 * 60 * 1000, internal.oauthStates.deleteById, {
|
|
48
|
+
id,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return id;
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Consume an OAuth state (read and delete). Used during the callback.
|
|
57
|
+
*/
|
|
58
|
+
export const consume = internalMutation({
|
|
59
|
+
args: { state: v.string() },
|
|
60
|
+
returns: v.any(),
|
|
61
|
+
handler: async (ctx, args) => {
|
|
62
|
+
const record = await ctx.db
|
|
63
|
+
.query("oauthStates")
|
|
64
|
+
.withIndex("by_state", (idx) => idx.eq("state", args.state))
|
|
65
|
+
.first();
|
|
66
|
+
|
|
67
|
+
if (!record) return null;
|
|
68
|
+
|
|
69
|
+
await ctx.db.delete(record._id);
|
|
70
|
+
return record;
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Delete an OAuth state by ID (used by scheduled cleanup).
|
|
76
|
+
*/
|
|
77
|
+
export const deleteById = internalMutation({
|
|
78
|
+
args: { id: v.id("oauthStates") },
|
|
79
|
+
handler: async (ctx, args) => {
|
|
80
|
+
const record = await ctx.db.get(args.id);
|
|
81
|
+
if (record) {
|
|
82
|
+
await ctx.db.delete(args.id);
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { internalMutation, internalQuery } from "./_generated/server";
|
|
3
|
+
import { providerName } from "./schema";
|
|
4
|
+
|
|
5
|
+
export const upsertCredentials = internalMutation({
|
|
6
|
+
args: {
|
|
7
|
+
provider: providerName,
|
|
8
|
+
clientId: v.string(),
|
|
9
|
+
clientSecret: v.string(),
|
|
10
|
+
subscriptionKey: v.optional(v.string()),
|
|
11
|
+
},
|
|
12
|
+
handler: async (ctx, args) => {
|
|
13
|
+
const existing = await ctx.db
|
|
14
|
+
.query("providerSettings")
|
|
15
|
+
.withIndex("by_provider", (idx) => idx.eq("provider", args.provider))
|
|
16
|
+
.first();
|
|
17
|
+
|
|
18
|
+
const patch = {
|
|
19
|
+
provider: args.provider,
|
|
20
|
+
isEnabled: true,
|
|
21
|
+
clientId: args.clientId,
|
|
22
|
+
clientSecret: args.clientSecret,
|
|
23
|
+
subscriptionKey: args.subscriptionKey,
|
|
24
|
+
updatedAt: Date.now(),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
if (existing) {
|
|
28
|
+
await ctx.db.patch(existing._id, patch);
|
|
29
|
+
return existing._id;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return await ctx.db.insert("providerSettings", patch);
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const getCredentials = internalQuery({
|
|
37
|
+
args: {
|
|
38
|
+
provider: providerName,
|
|
39
|
+
},
|
|
40
|
+
returns: v.union(
|
|
41
|
+
v.object({
|
|
42
|
+
provider: providerName,
|
|
43
|
+
clientId: v.string(),
|
|
44
|
+
clientSecret: v.string(),
|
|
45
|
+
subscriptionKey: v.optional(v.string()),
|
|
46
|
+
}),
|
|
47
|
+
v.null(),
|
|
48
|
+
),
|
|
49
|
+
handler: async (ctx, args) => {
|
|
50
|
+
const settings = await ctx.db
|
|
51
|
+
.query("providerSettings")
|
|
52
|
+
.withIndex("by_provider", (idx) => idx.eq("provider", args.provider))
|
|
53
|
+
.first();
|
|
54
|
+
|
|
55
|
+
if (!settings?.clientId || !settings.clientSecret) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
provider: settings.provider,
|
|
61
|
+
clientId: settings.clientId,
|
|
62
|
+
clientSecret: settings.clientSecret,
|
|
63
|
+
subscriptionKey: settings.subscriptionKey,
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
});
|