Skip to content

Commit 96ea639

Browse files
authored
feat: hybrid follow engine — LinkedIn WebView interaction, session management & deep-link fallback (#177)
* feat: Layer 2 WebView Follow Engine — LinkedIn In-App Connect + Session Management * fix: resolve TypeScript compilation issues and restore settings navigation * feat: WebView LinkedIn Connect Engine + Follow system (Section 6.9) - Backend: followRoutes returns webview strategy for LinkedIn/Twitter platforms - Backend: POST /api/follow/:platform/:targetUsername/log for telemetry - Backend: DELETE /api/follow/:platform/:targetUsername/log to reset Done state - Backend: public profile now returns followed:true for previously connected links - Backend: auth improvements — encode mobile redirect URI in OAuth state - Mobile: WebViewScreen — full LinkedIn JS injection engine with polling, MutationObserver, visibilitychange, popstate, and injectedJSBeforeContentLoaded - Mobile: DevCardViewScreen — premium UI, emoji icons, brand-colored buttons, Done tile with long-press reset, GitHub browser fallback - Mobile: HomeScreen — username search bar to view any DevCard profile - Mobile: App.tsx — hash fragment token extraction for OAuth deep links - Mobile: config.ts — auto-detects LAN IP via Expo Constants for Expo Go - Mobile: Expo migration — index.js, metro.config.js, babel.config.js, app.json - Tests: new follow.test.ts cases for webview strategy and log endpoint - Docs: README updated with telemetry and fallback overlay details - Config: docker-compose port 5433, .env.example LAN IP placeholders * fix: address PR review comments from Harxhit - prisma.ts: replace authenticate:any with proper typed signature (request: FastifyRequest, reply: FastifyReply) => Promise<void> - auth.ts: replace err as any with instanceof Error check in both GitHub and Google OAuth catch blocks for type-safe error handling - Skeleton.tsx: replace width/height as any with DimensionValue type from react-native to preserve TypeScript safety * fix: address remaining PR review comments from Harxhit - connect.ts: replace err as any with instanceof Error check in GitHub connect catch block (same pattern as auth.ts fix) - MainTabs.tsx: extract WebViewConnect params into standalone exported type WebViewConnectParams for reusability and future maintainability - profiles.test.ts: replace mockPrisma as any with Pick<PrismaClient,'user'> and unknown cast to preserve TypeScript safety in tests
1 parent 85c9551 commit 96ea639

24 files changed

Lines changed: 3458 additions & 1498 deletions

.env.example

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# ─── Database ───
2-
DATABASE_URL=postgresql://devcard:devcard@localhost:5432/devcard?schema=public
2+
DATABASE_URL=postgresql://devcard:devcard@localhost:5433/devcard?schema=public
33

44
# ─── Redis ───
55
REDIS_URL=redis://localhost:6379
@@ -19,8 +19,8 @@ GOOGLE_CLIENT_ID=your-google-client-id
1919
GOOGLE_CLIENT_SECRET=your-google-client-secret
2020

2121
# ─── App URLs ───
22-
PUBLIC_APP_URL=http://localhost:5173
23-
BACKEND_URL=http://localhost:3000
22+
PUBLIC_APP_URL=http://YOUR_COMPUTER_LAN_IP:5173
23+
BACKEND_URL=http://YOUR_COMPUTER_LAN_IP:3000
2424
MOBILE_REDIRECT_URI=devcard://oauth/callback
2525

2626
# ─── Server ───

apps/backend/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# DevCard Backend
2+
3+
## Follow Engine Architecture
4+
5+
DevCard implements a multi-layered Hybrid Follow Engine designed to connect platform professionals seamlessly while maintaining platform policy compliance.
6+
7+
```mermaid
8+
graph TD
9+
A[User triggers Follow/Connect] --> B{Check Platform Strategy}
10+
B -- api (GitHub) --> C[Layer 1: Direct OAuth API integration]
11+
B -- webview (LinkedIn) --> D[Layer 2: In-app WebView Interaction Engine]
12+
B -- link (GitLab/Devfolio) --> E[Layer 3: Native deep-linking / Browser redirect]
13+
B -- copy (Discord) --> F[Layer 4: Clipboard Copy fallback]
14+
```
15+
16+
### Layer 2: WebView Interaction Engine (LinkedIn)
17+
18+
Due to LinkedIn's modern API restrictions preventing programmatic connection requests, direct API follow (Layer 1) is not viable. Instead, the WebView Interaction Engine routes the action through a secure, native WebView:
19+
20+
1. **Routing Strategy**: The backend parses the connection request and returns `{ strategy: 'webview', url }` containing the resolved profile link.
21+
2. **Session Persistence**: The mobile WebView loads the target profile URL using system-level OAuth cookie-sharing (`sharedCookiesEnabled={true}`), ensuring the user remains authenticated.
22+
3. **DOM Introspection**: A lightweight JavaScript snippet is injected to continuously poll for the native LinkedIn 'Connect' button, smooth-scrolls it into view, and highlights it visually to encourage action.
23+
4. **Interactive Send**: Users retain full control over actual connection request submission, adhering completely to platform terms of service.
24+
5. **State Detection**:
25+
- URL State Polling: The engine inspects URL transitions containing `invite-sent` or similar sub-routes.
26+
- DOM Observation: The injected Javascript queries for structural indicators of successful invitation (e.g. "Pending" button state or toaster text) and posts a serialized message back to the native layer.
27+
6. **Robust Fallback**: If network or WebView loading times out (>10s), the engine gracefully falls back to native deep links (`linkedin://profile?id={username}`) or launches the default browser with an interactive custom in-app overlay.
28+
7. **Telemetry Logging**: Upon client-side success (detected via state changes or DOM indicators), the mobile app makes a `POST /api/follow/:platform/:targetUsername/log` request to the backend. This writes a record to the `FollowLog` database table for auditing and analytics tracking.

apps/backend/src/__tests__/follow.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,86 @@ describe('POST /api/follow/:platform/:targetUsername', () => {
5454

5555
await app.close();
5656
});
57+
58+
it('returns webview strategy and url for webview-strategy platforms (e.g. linkedin)', async () => {
59+
const app = Fastify({ logger: false });
60+
61+
app.decorate('prisma', {
62+
followLog: {
63+
create: vi.fn(),
64+
},
65+
} as any);
66+
67+
app.decorate('authenticate', async (request: any) => {
68+
request.user = { id: 'user-1' };
69+
});
70+
71+
await app.register(followRoutes, { prefix: '/api/follow' });
72+
await app.ready();
73+
74+
const response = await app.inject({
75+
method: 'POST',
76+
url: '/api/follow/linkedin/testuser',
77+
});
78+
79+
const body = response.json();
80+
81+
expect(response.statusCode).toBe(200);
82+
expect(body.strategy).toBe('webview');
83+
expect(body.url).toContain('linkedin.com/in/testuser');
84+
85+
await app.close();
86+
});
87+
88+
it('successfully logs a webview follow action', async () => {
89+
const app = Fastify({ logger: false });
90+
91+
const createLog = vi.fn().mockResolvedValue({
92+
id: 'log-1',
93+
followerId: 'user-1',
94+
targetUsername: 'testuser',
95+
platform: 'linkedin',
96+
status: 'success',
97+
layer: 'webview',
98+
});
99+
100+
app.decorate('prisma', {
101+
followLog: {
102+
create: createLog,
103+
},
104+
} as any);
105+
106+
app.decorate('authenticate', async (request: any) => {
107+
request.user = { id: 'user-1' };
108+
});
109+
110+
await app.register(followRoutes, { prefix: '/api/follow' });
111+
await app.ready();
112+
113+
const response = await app.inject({
114+
method: 'POST',
115+
url: '/api/follow/linkedin/testuser/log',
116+
payload: {
117+
status: 'success',
118+
layer: 'webview',
119+
},
120+
});
121+
122+
const body = response.json();
123+
124+
expect(response.statusCode).toBe(200);
125+
expect(body.status).toBe('success');
126+
expect(body.logId).toBe('log-1');
127+
expect(createLog).toHaveBeenCalledWith({
128+
data: {
129+
followerId: 'user-1',
130+
targetUsername: 'testuser',
131+
platform: 'linkedin',
132+
status: 'success',
133+
layer: 'webview',
134+
},
135+
});
136+
137+
await app.close();
138+
});
57139
});

apps/backend/src/__tests__/profiles.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect, beforeEach, vi } from 'vitest';
22
import Fastify from 'fastify';
33
import { profileRoutes } from '../routes/profiles.js';
4+
import type { PrismaClient } from '@prisma/client';
45

56
const mockUser = {
67
id: 'user-123',
@@ -19,17 +20,17 @@ const mockUser = {
1920
providerId: 'gh-123',
2021
};
2122

22-
const mockPrisma = {
23+
const mockPrisma: Pick<PrismaClient, 'user'> = {
2324
user: {
2425
findUnique: vi.fn(),
2526
findFirst: vi.fn(),
2627
update: vi.fn(),
27-
},
28+
} as unknown as PrismaClient['user'],
2829
};
2930

3031
async function buildApp() {
3132
const app = Fastify();
32-
app.decorate('prisma', mockPrisma);
33+
app.decorate('prisma', mockPrisma as unknown as PrismaClient);
3334
app.decorate('authenticate', async (request: any) => {
3435
request.user = { id: 'user-123' };
3536
});

apps/backend/src/plugins/prisma.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import fp from 'fastify-plugin';
22
import { PrismaClient } from '@prisma/client';
3-
import type { FastifyInstance } from 'fastify';
3+
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
44

55
declare module 'fastify' {
66
interface FastifyInstance {
7-
prisma: PrismaClient;
7+
prisma: PrismaClient;
88
authenticate(
9-
request: FastifyRequest,
10-
reply: FastifyReply
9+
request: FastifyRequest,
10+
reply: FastifyReply
1111
): Promise<void>;
1212
}
1313
}
14+
1415
export const prismaPlugin = fp(async (app: FastifyInstance) => {
1516
const prisma = new PrismaClient({
1617
log: process.env.NODE_ENV !== 'production' ? ['query', 'error', 'warn'] : ['error'],

apps/backend/src/routes/auth.ts

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
22
import { randomBytes } from 'crypto';
3+
34
const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize';
45
const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
56
const GITHUB_USER_URL = 'https://api.github.com/user';
@@ -13,12 +14,28 @@ interface OAuthCallbackQuery {
1314
}
1415

1516
export async function authRoutes(app: FastifyInstance) {
17+
// ─── Developer Login Bypass ───
18+
app.post('/dev-login', async (request: FastifyRequest, reply: FastifyReply) => {
19+
const user = await app.prisma.user.findUnique({
20+
where: { username: 'devcard-demo' },
21+
});
22+
if (!user) {
23+
return reply.status(404).send({ error: 'Demo user not seeded' });
24+
}
25+
const token = app.jwt.sign(
26+
{ id: user.id, username: user.username },
27+
{ expiresIn: '30d' }
28+
);
29+
return { token };
30+
});
31+
1632
// ─── GitHub OAuth ───
1733

1834
app.get('/github', async (request: FastifyRequest, reply: FastifyReply) => {
1935
const redirectUri = `${process.env.BACKEND_URL}/auth/github/callback`;
2036
const clientState = (request.query as any).state || '';
21-
const state = clientState ? `${clientState}_${generateState()}` : generateState();
37+
const mobileRedirectUri = (request.query as any).mobile_redirect_uri || '';
38+
const state = buildOAuthState(clientState, mobileRedirectUri);
2239

2340
const params = new URLSearchParams({
2441
client_id: (process.env.GITHUB_CLIENT_ID || '').trim(),
@@ -102,24 +119,15 @@ export async function authRoutes(app: FastifyInstance) {
102119
},
103120
});
104121

105-
// Save the authentication token for 'user:email read:user' so we have a basic platform connection
106-
const encryptedToken = (app as any).encryption ? (app as any).encryption.encrypt(tokenData.access_token) : tokenData.access_token;
107-
108-
await app.prisma.oAuthToken.upsert({
109-
where: { userId_platform: { userId: user.id, platform: 'github' } },
110-
update: { accessToken: encryptedToken, scopes: 'read:user user:email' },
111-
create: { userId: user.id, platform: 'github', accessToken: encryptedToken, scopes: 'read:user user:email' },
112-
});
113-
114122
// Generate JWT
115123
const token = app.jwt.sign(
116124
{ id: user.id, username: user.username },
117125
{ expiresIn: '30d' }
118126
);
119127

120128
// For mobile app: redirect with token as URL fragment (not sent to servers, keeps token out of logs)
121-
const mobileRedirect = process.env.MOBILE_REDIRECT_URI;
122129
if (request.query.state?.startsWith('mobile_')) {
130+
const mobileRedirect = getMobileRedirectUri(request.query.state) || process.env.MOBILE_REDIRECT_URI;
123131
return reply.redirect(`${mobileRedirect}#token=${token}`);
124132
}
125133

@@ -134,7 +142,8 @@ export async function authRoutes(app: FastifyInstance) {
134142

135143
return reply.redirect(`${process.env.PUBLIC_APP_URL}/dashboard`);
136144
} catch (err) {
137-
app.log.error('GitHub auth error:', err);
145+
const message = err instanceof Error ? err.message : String(err);
146+
app.log.error({ err, message }, 'GitHub auth error');
138147
return reply.status(500).send({ error: 'Authentication failed' });
139148
}
140149
});
@@ -144,7 +153,8 @@ export async function authRoutes(app: FastifyInstance) {
144153
app.get('/google', async (request: FastifyRequest, reply: FastifyReply) => {
145154
const redirectUri = `${process.env.BACKEND_URL}/auth/google/callback`;
146155
const clientState = (request.query as any).state || '';
147-
const state = clientState ? `${clientState}_${generateState()}` : generateState();
156+
const mobileRedirectUri = (request.query as any).mobile_redirect_uri || '';
157+
const state = buildOAuthState(clientState, mobileRedirectUri);
148158

149159
const params = new URLSearchParams({
150160
client_id: (process.env.GOOGLE_CLIENT_ID || '').trim(),
@@ -221,7 +231,7 @@ export async function authRoutes(app: FastifyInstance) {
221231
);
222232

223233
if (request.query.state?.startsWith('mobile_')) {
224-
const mobileRedirect = process.env.MOBILE_REDIRECT_URI;
234+
const mobileRedirect = getMobileRedirectUri(request.query.state) || process.env.MOBILE_REDIRECT_URI;
225235
return reply.redirect(`${mobileRedirect}#token=${token}`);
226236
}
227237

@@ -235,7 +245,8 @@ export async function authRoutes(app: FastifyInstance) {
235245

236246
return reply.redirect(`${process.env.PUBLIC_APP_URL}/dashboard`);
237247
} catch (err) {
238-
app.log.error('Google auth error:', err);
248+
const message = err instanceof Error ? err.message : String(err);
249+
app.log.error({ err, message }, 'Google auth error');
239250
return reply.status(500).send({ error: 'Authentication failed' });
240251
}
241252
});
@@ -289,3 +300,33 @@ export async function authRoutes(app: FastifyInstance) {
289300
function generateState(): string {
290301
return randomBytes(32).toString('hex');
291302
}
303+
304+
function buildOAuthState(clientState: string, mobileRedirectUri: string): string {
305+
if (!clientState) {
306+
return generateState();
307+
}
308+
309+
if (clientState.startsWith('mobile_') && mobileRedirectUri) {
310+
const encodedRedirect = Buffer.from(mobileRedirectUri, 'utf8').toString('base64url');
311+
return `${clientState}.${encodedRedirect}.${generateState()}`;
312+
}
313+
314+
return `${clientState}.${generateState()}`;
315+
}
316+
317+
function getMobileRedirectUri(state?: string): string | null {
318+
if (!state?.startsWith('mobile_')) {
319+
return null;
320+
}
321+
322+
const encodedRedirect = state.split('.')[1];
323+
if (!encodedRedirect) {
324+
return null;
325+
}
326+
327+
try {
328+
return Buffer.from(encodedRedirect, 'base64url').toString('utf8');
329+
} catch {
330+
return null;
331+
}
332+
}

apps/backend/src/routes/connect.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2+
import { randomBytes } from 'crypto';
3+
import { encrypt } from '../utils/encryption.js';
24

35
const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize';
46
const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
@@ -95,7 +97,7 @@ export async function connectRoutes(app: FastifyInstance) {
9597
}
9698

9799
// Encrypt and store the token
98-
const encryptedToken = app.encryption.encrypt(tokenData.access_token);
100+
const encryptedToken = encrypt(tokenData.access_token);
99101

100102
await app.prisma.oAuthToken.upsert({
101103
where: {
@@ -125,7 +127,8 @@ export async function connectRoutes(app: FastifyInstance) {
125127
return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?connected=github`);
126128

127129
} catch (err) {
128-
app.log.error('GitHub connect error:', err);
130+
const message = err instanceof Error ? err.message : String(err);
131+
app.log.error({ err, message }, 'GitHub connect error');
129132
return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=server_error`);
130133
}
131134
});

0 commit comments

Comments
 (0)