Skip to content

Commit 0fb11c3

Browse files
authored
Merge pull request #244 from vechain/mike/api-gw-caching
Refactored lambda to check denylist and then try usercheck, tests also added
2 parents 1291067 + 0ee3819 commit 0fb11c3

File tree

8 files changed

+571
-122
lines changed

8 files changed

+571
-122
lines changed

.github/workflows/deploy-api-lambda.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ on:
44
push:
55
branches:
66
- main
7-
paths: ['lambda/**', '.github/workflows/deploy-api-lambda.yaml']
7+
paths: ['lambda/**', '!lambda/tests/**', '.github/workflows/deploy-api-lambda.yaml']
88

99
permissions:
1010
contents: read
@@ -40,4 +40,4 @@ jobs:
4040
--region ${{ env.AWS_REGION }} \
4141
--capabilities CAPABILITY_IAM \
4242
--no-fail-on-empty-changeset \
43-
--parameter-overrides "PrivyAppId=${{ secrets.NEXT_PUBLIC_PRIVY_APP_ID }} PrivyAppSecret=${{ secrets.NEXT_PUBLIC_PRIVY_APP_SECRET }}"
43+
--parameter-overrides "PrivyAppId=${{ secrets.NEXT_PUBLIC_PRIVY_APP_ID }} PrivyAppSecret=${{ secrets.NEXT_PUBLIC_PRIVY_APP_SECRET }} UserCheckApiKey=${{ secrets.USERCHECK_API_KEY }}"

.github/workflows/test-lambda.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: Lambda Tests
2+
3+
on:
4+
pull_request:
5+
paths: ['lambda/tests/**', '.github/workflows/test-lambda.yml']
6+
7+
jobs:
8+
test:
9+
name: Run Tests
10+
runs-on: ubuntu-latest
11+
defaults:
12+
run:
13+
working-directory: lambda
14+
15+
steps:
16+
- uses: actions/checkout@v4
17+
18+
- name: Setup Node.js
19+
uses: actions/setup-node@v4
20+
with:
21+
node-version: '20'
22+
cache: 'yarn'
23+
24+
- name: Install dependencies
25+
run: yarn install
26+
27+
- name: Run tests
28+
run: yarn test

lambda/.env.local.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
2-
"GetEmbeddedWalletDetailsFunction": {
3-
"PRIVY_APP_ID": "",
4-
"PRIVY_APP_SECRET": ""
5-
}
2+
"GetEmbeddedWalletDetailsFunction": {
3+
"PRIVY_APP_ID": "",
4+
"PRIVY_APP_SECRET": "",
5+
"USERCHECK_API_KEY": ""
6+
}
67
}

lambda/index.ts

Lines changed: 152 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
22
import { PrivyClient, User } from '@privy-io/server-auth';
3+
import axios from 'axios';
34

45
// Define types for all identifiers
56
type UserIdentifiers = {
7+
[key: string]: string | boolean | undefined;
68
email?: string;
79
google?: string;
810
apple?: string;
@@ -12,6 +14,7 @@ type UserIdentifiers = {
1214
telegram?: string;
1315
instagram?: string;
1416
linkedin?: string;
17+
isDisposable?: boolean;
1518
};
1619

1720
// Define social account types for type safety
@@ -26,12 +29,122 @@ type SocialAccountType =
2629
| 'instagram_oauth'
2730
| 'linkedin_oauth';
2831

32+
interface LinkedAccount {
33+
type: string;
34+
email?: string | null;
35+
address?: string | null;
36+
username?: string | null;
37+
}
38+
39+
const PRIVY_APP_ID = process.env.PRIVY_APP_ID as string;
40+
const PRIVY_APP_SECRET = process.env.PRIVY_APP_SECRET as string;
41+
42+
export async function addToDenylist(email: string): Promise<boolean> {
43+
const emailDomain = email.split('@')[1];
44+
try {
45+
const response = await axios.post(`https://auth.privy.io/api/v1/apps/${PRIVY_APP_ID}/denylist`, {
46+
type: 'emailDomain',
47+
value: emailDomain
48+
}, {
49+
auth: {
50+
username: PRIVY_APP_ID,
51+
password: PRIVY_APP_SECRET
52+
},
53+
headers: {
54+
'privy-app-id': PRIVY_APP_ID
55+
}
56+
});
57+
if (response.status === 200) {
58+
return true;
59+
} else {
60+
console.error('Failed to add domain to denylist. API returned error:', response.data);
61+
return false;
62+
}
63+
} catch (error) {
64+
console.error('Unexpected error while adding domain to denylist:', error);
65+
return false;
66+
}
67+
}
68+
69+
export async function checkPrivyDenylist(email: string): Promise<boolean> {
70+
const emailDomain = email.split('@')[1];
71+
try {
72+
const response = await axios.get(`https://auth.privy.io/api/v1/apps/${PRIVY_APP_ID}/denylist`, { // Initial request to get the denylist, no cursor query parameter
73+
auth: {
74+
username: PRIVY_APP_ID,
75+
password: PRIVY_APP_SECRET
76+
},
77+
headers: {
78+
'privy-app-id': PRIVY_APP_ID
79+
}
80+
});
81+
82+
if (!response.data.data) {
83+
return false;
84+
}
85+
86+
const exists = response.data.data.some((dataObject: { value: string }) => dataObject.value === emailDomain);
87+
if (exists) {
88+
return true;
89+
}
90+
91+
let cursor = response.data?.next_cursor;
92+
while (cursor) {
93+
const nextResponse = await axios.get(`https://auth.privy.io/api/v1/apps/${PRIVY_APP_ID}/denylist?cursor=${cursor}`, {
94+
auth: {
95+
username: PRIVY_APP_ID,
96+
password: PRIVY_APP_SECRET
97+
},
98+
headers: {
99+
'privy-app-id': PRIVY_APP_ID
100+
}
101+
});
102+
103+
const exists = nextResponse.data.data.some((dataObject: { value: string }) => dataObject.value === emailDomain);
104+
if (exists) {
105+
return true;
106+
}
107+
cursor = nextResponse.data.next_cursor;
108+
}
109+
return false;
110+
} catch (error) {
111+
console.error('Error checking email disposability:', error);
112+
return false;
113+
}
114+
}
115+
116+
export async function tryUserCheck(email: string): Promise<boolean> {
117+
const USERCHECK_API_KEY = process.env.USERCHECK_API_KEY as string;
118+
if (!USERCHECK_API_KEY) {
119+
return false;
120+
}
121+
try {
122+
const response = await axios.get(`https://api.usercheck.com/domain/${email.split('@')[1]}`, {
123+
headers: {
124+
'Authorization': `Bearer ${USERCHECK_API_KEY}`
125+
}
126+
});
127+
if (response.status === 200) {
128+
return response.data.disposable;
129+
} else if (response.status === 400) {
130+
console.error('Error domain is invalid:', response.data);
131+
return false;
132+
} else { // Should be only status 429
133+
console.error('Error too many requests:', response.data);
134+
return false;
135+
}
136+
} catch (error) {
137+
console.error('Error checking email disposability:', error);
138+
return false;
139+
}
140+
}
141+
29142
/**
30-
* Extracts user identifiers (email and social media usernames) from a Privy user
31-
* @param user - The Privy user object
32-
* @returns UserIdentifiers object containing all identifiers
33-
*/
34-
function getUserIdentifiers(user: User): UserIdentifiers {
143+
* Get the identifiers for the user
144+
* @param user - The user object from Privy
145+
* @returns The identifiers for the user
146+
*/
147+
export function getUserIdentifiers(user: User): UserIdentifiers {
35148
const identifiers: UserIdentifiers = {};
36149

37150
// Get email if available
@@ -40,24 +153,22 @@ function getUserIdentifiers(user: User): UserIdentifiers {
40153
}
41154

42155
// Get usernames from linked accounts
43-
user.linkedAccounts.forEach(account => {
156+
user.linkedAccounts.forEach((account: LinkedAccount) => {
44157
const accountType = account.type as SocialAccountType;
45158
// Remove '_oauth' suffix to get the field name
46159
const field = accountType.replace('_oauth', '') as keyof UserIdentifiers;
47160

48161
// Handle email-based accounts (email, google, apple, linkedin)
49162
if (accountType === 'email' || accountType === 'google_oauth' || accountType === 'apple_oauth' || accountType === 'linkedin_oauth') {
50-
const emailAccount = account as { email?: string } | { address?: string };
51-
if ('email' in emailAccount) {
52-
identifiers[field] = emailAccount.email;
53-
} else if ('address' in emailAccount) {
54-
identifiers[field] = emailAccount.address;
163+
if (account.email) {
164+
identifiers[field] = account.email || undefined;
165+
} else if (account.address) {
166+
identifiers[field] = account.address || undefined;
55167
}
56168
}
57169
// Handle username-based accounts
58-
else {
59-
const usernameAccount = account as { username?: string };
60-
identifiers[field] = usernameAccount.username || undefined;
170+
else if (account.username) {
171+
identifiers[field] = account.username || undefined;
61172
}
62173
});
63174
return identifiers;
@@ -67,7 +178,6 @@ export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGat
67178
try {
68179
// Get wallet address from query parameters
69180
const walletAddress = event.pathParameters?.walletAddress;
70-
71181
if (!walletAddress) {
72182
return {
73183
statusCode: 422,
@@ -77,13 +187,17 @@ export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGat
77187
};
78188
}
79189

190+
if (!PRIVY_APP_ID || !PRIVY_APP_SECRET) {
191+
throw new Error('Missing required environment variables');
192+
}
193+
80194
const privy = new PrivyClient(
81-
process.env.PRIVY_APP_ID!,
82-
process.env.PRIVY_APP_SECRET!
195+
PRIVY_APP_ID,
196+
PRIVY_APP_SECRET
83197
);
84198

85199
const user = await privy.getUserByWalletAddress(walletAddress);
86-
200+
87201
if (!user) {
88202
return {
89203
statusCode: 404,
@@ -92,17 +206,35 @@ export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGat
92206
}),
93207
};
94208
}
209+
95210
const identifiers = getUserIdentifiers(user);
211+
212+
if (identifiers.email) {
213+
try {
214+
const isInDenylist = await checkPrivyDenylist(identifiers.email);
215+
identifiers.isDisposable = isInDenylist;
216+
if (!isInDenylist) {
217+
const isDisposable = await tryUserCheck(identifiers.email);
218+
identifiers.isDisposable = isDisposable;
219+
if (isDisposable) {
220+
await addToDenylist(identifiers.email);
221+
}
222+
}
223+
} catch (error) {
224+
console.error('Error checking email disposability:', error);
225+
}
226+
}
227+
96228
return {
97229
statusCode: 200,
98230
body: JSON.stringify(identifiers),
99231
};
100232
} catch (err) {
101-
console.log(err);
233+
console.error('Error processing request:', err);
102234
return {
103235
statusCode: 500,
104236
body: JSON.stringify({
105-
message: 'Some error happened',
237+
message: 'Internal server error',
106238
}),
107239
};
108240
}

lambda/package.json

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,48 @@
66
"repository": "https://github.com/vechain/vechain-kit",
77
"author": "VeChain Kit",
88
"license": "MIT",
9+
"scripts": {
10+
"build": "tsc",
11+
"watch": "tsc -w",
12+
"test": "jest",
13+
"test:watch": "jest --watch",
14+
"test:coverage": "jest --coverage"
15+
},
916
"dependencies": {
10-
"@privy-io/server-auth": "^1.19.1",
17+
"@privy-io/public-api": "^2.20.5",
18+
"@privy-io/server-auth": "^1.19.3",
19+
"axios": "^1.6.7",
20+
"dotenv": "^16.4.5",
1121
"esbuild": "^0.14.14",
12-
"viem": "^2.7.9",
13-
"ethers": "^6.11.0"
22+
"ethers": "^6.11.0",
23+
"viem": "^2.7.9"
1424
},
1525
"devDependencies": {
16-
"@types/aws-lambda": "^8.10.92",
17-
"@types/node": "^20.5.7",
18-
"@typescript-eslint/eslint-plugin": "^5.10.2",
19-
"@typescript-eslint/parser": "^5.10.2",
20-
"eslint": "^8.8.0",
21-
"eslint-config-prettier": "^8.3.0",
22-
"eslint-plugin-prettier": "^4.0.0",
23-
"typescript": "^5.0.4"
26+
"@types/aws-lambda": "^8.10.119",
27+
"@types/jest": "^29.5.12",
28+
"@types/node": "^20.11.19",
29+
"@typescript-eslint/eslint-plugin": "^8.29.1",
30+
"@typescript-eslint/parser": "^8.29.1",
31+
"dotenv-cli": "^8.0.0",
32+
"eslint": "^9.24.0",
33+
"eslint-config-prettier": "^10.1.1",
34+
"eslint-plugin-prettier": "^5.2.6",
35+
"jest": "^29.7.0",
36+
"ts-jest": "^29.1.2",
37+
"typescript": "^5.8.3"
38+
},
39+
"jest": {
40+
"preset": "ts-jest",
41+
"testEnvironment": "node",
42+
"moduleFileExtensions": [
43+
"ts",
44+
"js"
45+
],
46+
"transform": {
47+
"^.+\\.ts$": "ts-jest"
48+
},
49+
"testMatch": [
50+
"**/tests/**/*.test.ts"
51+
]
2452
}
2553
}

0 commit comments

Comments
 (0)