tangled
alpha
login
or
join now
byarielm.fyi
/
atlast
16
fork
atom
ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
16
fork
atom
overview
issues
1
pulls
pipelines
combine session + profile endpts; add response caching
byarielm.fyi
4 months ago
b098fd8e
5c3cd5c4
+272
-146
4 changed files
expand all
collapse all
unified
split
netlify
functions
get-profile.ts
session.ts
src
hooks
useAuth.ts
lib
apiClient.ts
-100
netlify/functions/get-profile.ts
···
1
1
-
import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions';
2
2
-
import { NodeOAuthClient } from '@atproto/oauth-client-node';
3
3
-
import { JoseKey } from '@atproto/jwk-jose';
4
4
-
import { stateStore, sessionStore, userSessions } from './oauth-stores-db';
5
5
-
import { getOAuthConfig } from './oauth-config';
6
6
-
import { Agent } from '@atproto/api';
7
7
-
import cookie from 'cookie';
8
8
-
9
9
-
function normalizePrivateKey(key: string): string {
10
10
-
if (!key.includes('\n') && key.includes('\\n')) {
11
11
-
return key.replace(/\\n/g, '\n');
12
12
-
}
13
13
-
return key;
14
14
-
}
15
15
-
16
16
-
export const handler: Handler = async (event: HandlerEvent): Promise<HandlerResponse> => {
17
17
-
try {
18
18
-
// Get session from cookie
19
19
-
const cookies = event.headers.cookie ? cookie.parse(event.headers.cookie) : {};
20
20
-
const sessionId = cookies.atlast_session;
21
21
-
22
22
-
if (!sessionId) {
23
23
-
return {
24
24
-
statusCode: 401,
25
25
-
headers: { 'Content-Type': 'application/json' },
26
26
-
body: JSON.stringify({ error: 'No session cookie' }),
27
27
-
};
28
28
-
}
29
29
-
30
30
-
// Get DID from session
31
31
-
const userSession = await userSessions.get(sessionId);
32
32
-
if (!userSession) {
33
33
-
return {
34
34
-
statusCode: 401,
35
35
-
headers: { 'Content-Type': 'application/json' },
36
36
-
body: JSON.stringify({ error: 'Invalid or expired session' }),
37
37
-
};
38
38
-
}
39
39
-
40
40
-
// Initialize OAuth client
41
41
-
const config = getOAuthConfig();
42
42
-
const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY!);
43
43
-
const privateKey = await JoseKey.fromImportable(normalizedKey, 'main-key');
44
44
-
45
45
-
const client = new NodeOAuthClient({
46
46
-
clientMetadata: {
47
47
-
client_id: config.clientId,
48
48
-
client_name: 'ATlast',
49
49
-
client_uri: config.clientId.replace('/client-metadata.json', ''),
50
50
-
redirect_uris: [config.redirectUri],
51
51
-
scope: 'atproto transition:generic',
52
52
-
grant_types: ['authorization_code', 'refresh_token'],
53
53
-
response_types: ['code'],
54
54
-
application_type: 'web',
55
55
-
token_endpoint_auth_method: 'private_key_jwt',
56
56
-
token_endpoint_auth_signing_alg: 'ES256',
57
57
-
dpop_bound_access_tokens: true,
58
58
-
jwks_uri: config.jwksUri,
59
59
-
},
60
60
-
keyset: [privateKey],
61
61
-
stateStore: stateStore as any,
62
62
-
sessionStore: sessionStore as any,
63
63
-
});
64
64
-
65
65
-
// Restore OAuth session
66
66
-
const oauthSession = await client.restore(userSession.did);
67
67
-
68
68
-
// Create agent from OAuth session
69
69
-
const agent = new Agent(oauthSession);
70
70
-
71
71
-
// Get profile
72
72
-
const profile = await agent.getProfile({ actor: userSession.did });
73
73
-
74
74
-
return {
75
75
-
statusCode: 200,
76
76
-
headers: {
77
77
-
'Content-Type': 'application/json',
78
78
-
'Access-Control-Allow-Origin': '*',
79
79
-
},
80
80
-
body: JSON.stringify({
81
81
-
did: userSession.did,
82
82
-
handle: profile.data.handle,
83
83
-
displayName: profile.data.displayName,
84
84
-
avatar: profile.data.avatar,
85
85
-
description: profile.data.description,
86
86
-
}),
87
87
-
};
88
88
-
89
89
-
} catch (error) {
90
90
-
console.error('Get profile error:', error);
91
91
-
return {
92
92
-
statusCode: 500,
93
93
-
headers: { 'Content-Type': 'application/json' },
94
94
-
body: JSON.stringify({
95
95
-
error: 'Failed to get profile',
96
96
-
details: error instanceof Error ? error.message : 'Unknown error'
97
97
-
}),
98
98
-
};
99
99
-
}
100
100
-
};
+114
-14
netlify/functions/session.ts
···
1
1
import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions';
2
2
-
import { userSessions } from './oauth-stores-db';
2
2
+
import { NodeOAuthClient } from '@atproto/oauth-client-node';
3
3
+
import { JoseKey } from '@atproto/jwk-jose';
4
4
+
import { stateStore, sessionStore, userSessions } from './oauth-stores-db';
5
5
+
import { getOAuthConfig } from './oauth-config';
6
6
+
import { Agent } from '@atproto/api';
3
7
import cookie from 'cookie';
8
8
+
9
9
+
function normalizePrivateKey(key: string): string {
10
10
+
if (!key.includes('\n') && key.includes('\\n')) {
11
11
+
return key.replace(/\\n/g, '\n');
12
12
+
}
13
13
+
return key;
14
14
+
}
15
15
+
16
16
+
// In-memory cache for profile data (lives for the function instance lifetime)
17
17
+
const profileCache = new Map<string, { data: any; timestamp: number }>();
18
18
+
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
4
19
5
20
export const handler: Handler = async (event: HandlerEvent): Promise<HandlerResponse> => {
6
21
try {
···
25
40
};
26
41
}
27
42
28
28
-
// For now, return minimal info
29
29
-
// The OAuth client manages the actual tokens in sessionStore
30
30
-
return {
31
31
-
statusCode: 200,
32
32
-
headers: {
33
33
-
'Content-Type': 'application/json',
34
34
-
'Access-Control-Allow-Origin': '*',
35
35
-
},
36
36
-
body: JSON.stringify({
43
43
+
// Check cache first
44
44
+
const cached = profileCache.get(userSession.did);
45
45
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
46
46
+
console.log('Returning cached profile for', userSession.did);
47
47
+
return {
48
48
+
statusCode: 200,
49
49
+
headers: {
50
50
+
'Content-Type': 'application/json',
51
51
+
'Access-Control-Allow-Origin': '*',
52
52
+
'Cache-Control': 'private, max-age=300', // Browser can cache for 5 minutes
53
53
+
},
54
54
+
body: JSON.stringify(cached.data),
55
55
+
};
56
56
+
}
57
57
+
58
58
+
// If not in cache, fetch full profile
59
59
+
try {
60
60
+
const config = getOAuthConfig();
61
61
+
const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY!);
62
62
+
const privateKey = await JoseKey.fromImportable(normalizedKey, 'main-key');
63
63
+
64
64
+
const client = new NodeOAuthClient({
65
65
+
clientMetadata: {
66
66
+
client_id: config.clientId,
67
67
+
client_name: 'ATlast',
68
68
+
client_uri: config.clientId.replace('/client-metadata.json', ''),
69
69
+
redirect_uris: [config.redirectUri],
70
70
+
scope: 'atproto transition:generic',
71
71
+
grant_types: ['authorization_code', 'refresh_token'],
72
72
+
response_types: ['code'],
73
73
+
application_type: 'web',
74
74
+
token_endpoint_auth_method: 'private_key_jwt',
75
75
+
token_endpoint_auth_signing_alg: 'ES256',
76
76
+
dpop_bound_access_tokens: true,
77
77
+
jwks_uri: config.jwksUri,
78
78
+
},
79
79
+
keyset: [privateKey],
80
80
+
stateStore: stateStore as any,
81
81
+
sessionStore: sessionStore as any,
82
82
+
});
83
83
+
84
84
+
// Restore OAuth session
85
85
+
const oauthSession = await client.restore(userSession.did);
86
86
+
87
87
+
// Create agent from OAuth session
88
88
+
const agent = new Agent(oauthSession);
89
89
+
90
90
+
// Get profile
91
91
+
const profile = await agent.getProfile({ actor: userSession.did });
92
92
+
93
93
+
const profileData = {
37
94
did: userSession.did,
38
38
-
// We'll add handle and serviceEndpoint in the next phase
39
39
-
// when we can restore the OAuth session
40
40
-
}),
41
41
-
};
95
95
+
handle: profile.data.handle,
96
96
+
displayName: profile.data.displayName,
97
97
+
avatar: profile.data.avatar,
98
98
+
description: profile.data.description,
99
99
+
};
100
100
+
101
101
+
// Cache the profile data
102
102
+
profileCache.set(userSession.did, {
103
103
+
data: profileData,
104
104
+
timestamp: Date.now(),
105
105
+
});
106
106
+
107
107
+
// Clean up old cache entries (simple cleanup)
108
108
+
if (profileCache.size > 100) {
109
109
+
const now = Date.now();
110
110
+
for (const [did, entry] of profileCache.entries()) {
111
111
+
if (now - entry.timestamp > CACHE_TTL) {
112
112
+
profileCache.delete(did);
113
113
+
}
114
114
+
}
115
115
+
}
116
116
+
117
117
+
return {
118
118
+
statusCode: 200,
119
119
+
headers: {
120
120
+
'Content-Type': 'application/json',
121
121
+
'Access-Control-Allow-Origin': '*',
122
122
+
'Cache-Control': 'private, max-age=300', // Browser can cache for 5 minutes
123
123
+
},
124
124
+
body: JSON.stringify(profileData),
125
125
+
};
126
126
+
} catch (error) {
127
127
+
console.error('Profile fetch error:', error);
128
128
+
129
129
+
// If profile fetch fails, return basic session info
130
130
+
return {
131
131
+
statusCode: 200,
132
132
+
headers: {
133
133
+
'Content-Type': 'application/json',
134
134
+
'Access-Control-Allow-Origin': '*',
135
135
+
},
136
136
+
body: JSON.stringify({
137
137
+
did: userSession.did,
138
138
+
// Profile data unavailable
139
139
+
}),
140
140
+
};
141
141
+
}
42
142
} catch (error) {
43
143
console.error('Session error:', error);
44
144
return {
+19
-18
src/hooks/useAuth.ts
···
27
27
// If we have a session parameter in URL, this is an OAuth callback
28
28
if (sessionId) {
29
29
setStatusMessage('Loading your session...');
30
30
-
await fetchProfile();
30
30
+
31
31
+
// Single call now gets both session AND profile data
32
32
+
const data = await apiClient.getSession();
33
33
+
setSession({
34
34
+
did: data.did,
35
35
+
handle: data.handle,
36
36
+
displayName: data.displayName,
37
37
+
avatar: data.avatar,
38
38
+
description: data.description,
39
39
+
});
31
40
setCurrentStep('home');
41
41
+
setStatusMessage(`Welcome back, ${data.handle}!`);
42
42
+
32
43
window.history.replaceState({}, '', '/');
33
44
return;
34
45
}
35
46
36
47
// Otherwise, check if there's an existing session cookie
37
37
-
await apiClient.getSession();
38
38
-
await fetchProfile();
39
39
-
setCurrentStep('home');
40
40
-
} catch (error) {
41
41
-
console.error('Session check error:', error);
42
42
-
setCurrentStep('login');
43
43
-
}
44
44
-
}
45
45
-
46
46
-
async function fetchProfile() {
47
47
-
try {
48
48
-
const data = await apiClient.getProfile();
48
48
+
// Single call now gets both session AND profile data
49
49
+
const data = await apiClient.getSession();
49
50
setSession({
50
51
did: data.did,
51
52
handle: data.handle,
···
53
54
avatar: data.avatar,
54
55
description: data.description,
55
56
});
56
56
-
setStatusMessage(`Successfully logged in as ${data.handle}`);
57
57
-
} catch (err) {
58
58
-
console.error('Profile fetch error:', err);
59
59
-
setStatusMessage('Failed to load profile');
60
60
-
throw err;
57
57
+
setCurrentStep('home');
58
58
+
setStatusMessage(`Welcome back, ${data.handle}!`);
59
59
+
} catch (error) {
60
60
+
console.error('Session check error:', error);
61
61
+
setCurrentStep('login');
61
62
}
62
63
}
63
64
+139
-14
src/lib/apiClient.ts
···
1
1
import type { AtprotoSession, BatchSearchResult, BatchFollowResult, SaveResultsResponse, SearchResult } from '../types';
2
2
3
3
+
// Client-side cache with TTL
4
4
+
interface CacheEntry<T> {
5
5
+
data: T;
6
6
+
timestamp: number;
7
7
+
}
8
8
+
9
9
+
class ResponseCache {
10
10
+
private cache = new Map<string, CacheEntry<any>>();
11
11
+
private readonly defaultTTL = 5 * 60 * 1000; // 5 minutes
12
12
+
13
13
+
set<T>(key: string, data: T, ttl: number = this.defaultTTL): void {
14
14
+
this.cache.set(key, {
15
15
+
data,
16
16
+
timestamp: Date.now(),
17
17
+
});
18
18
+
19
19
+
// Clean up old entries periodically
20
20
+
if (this.cache.size > 50) {
21
21
+
this.cleanup();
22
22
+
}
23
23
+
}
24
24
+
25
25
+
get<T>(key: string, ttl: number = this.defaultTTL): T | null {
26
26
+
const entry = this.cache.get(key);
27
27
+
if (!entry) return null;
28
28
+
29
29
+
if (Date.now() - entry.timestamp > ttl) {
30
30
+
this.cache.delete(key);
31
31
+
return null;
32
32
+
}
33
33
+
34
34
+
return entry.data as T;
35
35
+
}
36
36
+
37
37
+
invalidate(key: string): void {
38
38
+
this.cache.delete(key);
39
39
+
}
40
40
+
41
41
+
invalidatePattern(pattern: string): void {
42
42
+
for (const key of this.cache.keys()) {
43
43
+
if (key.includes(pattern)) {
44
44
+
this.cache.delete(key);
45
45
+
}
46
46
+
}
47
47
+
}
48
48
+
49
49
+
clear(): void {
50
50
+
this.cache.clear();
51
51
+
}
52
52
+
53
53
+
private cleanup(): void {
54
54
+
const now = Date.now();
55
55
+
for (const [key, entry] of this.cache.entries()) {
56
56
+
if (now - entry.timestamp > this.defaultTTL) {
57
57
+
this.cache.delete(key);
58
58
+
}
59
59
+
}
60
60
+
}
61
61
+
}
62
62
+
63
63
+
const cache = new ResponseCache();
64
64
+
3
65
export const apiClient = {
4
66
// OAuth and Authentication
5
67
async startOAuth(handle: string): Promise<{ url: string }> {
···
23
85
},
24
86
25
87
async getSession(): Promise<{ did: string; handle: string; displayName?: string; avatar?: string; description?: string }> {
88
88
+
// Check cache first
89
89
+
const cacheKey = 'session';
90
90
+
const cached = cache.get<AtprotoSession>(cacheKey);
91
91
+
if (cached) {
92
92
+
console.log('Returning cached session');
93
93
+
return cached;
94
94
+
}
95
95
+
26
96
const res = await fetch('/.netlify/functions/session', {
27
97
credentials: 'include'
28
98
});
···
31
101
throw new Error('No valid session');
32
102
}
33
103
34
34
-
return res.json();
104
104
+
const data = await res.json();
105
105
+
106
106
+
// Cache the session data for 5 minutes
107
107
+
cache.set(cacheKey, data, 5 * 60 * 1000);
108
108
+
109
109
+
return data;
35
110
},
36
111
37
112
async getProfile(): Promise<AtprotoSession> {
38
38
-
const res = await fetch('/.netlify/functions/get-profile', {
39
39
-
credentials: 'include'
40
40
-
});
41
41
-
42
42
-
if (!res.ok) {
43
43
-
throw new Error('Failed to load profile');
44
44
-
}
45
45
-
46
46
-
return res.json();
113
113
+
// This is now redundant - getSession returns profile data
114
114
+
// Keeping for backwards compatibility but it just calls getSession
115
115
+
return this.getSession();
47
116
},
48
117
49
118
async logout(): Promise<void> {
···
55
124
if (!res.ok) {
56
125
throw new Error('Logout failed');
57
126
}
127
127
+
128
128
+
// Clear all caches on logout
129
129
+
cache.clear();
58
130
},
59
131
60
132
// Upload History Operations
···
68
140
unmatchedUsers: number;
69
141
}>;
70
142
}> {
143
143
+
// Check cache first
144
144
+
const cacheKey = 'uploads';
145
145
+
const cached = cache.get<any>(cacheKey, 2 * 60 * 1000); // 2 minute cache for uploads list
146
146
+
if (cached) {
147
147
+
console.log('Returning cached uploads');
148
148
+
return cached;
149
149
+
}
150
150
+
71
151
const res = await fetch('/.netlify/functions/get-uploads', {
72
152
credentials: 'include'
73
153
});
···
76
156
throw new Error('Failed to fetch uploads');
77
157
}
78
158
79
79
-
return res.json();
159
159
+
const data = await res.json();
160
160
+
161
161
+
// Cache uploads list for 2 minutes
162
162
+
cache.set(cacheKey, data, 2 * 60 * 1000);
163
163
+
164
164
+
return data;
80
165
},
81
166
82
167
async getUploadDetails(uploadId: string): Promise<{
83
168
results: SearchResult[];
84
169
}> {
170
170
+
// Check cache first
171
171
+
const cacheKey = `upload-details-${uploadId}`;
172
172
+
const cached = cache.get<any>(cacheKey, 10 * 60 * 1000); // 10 minute cache for specific upload
173
173
+
if (cached) {
174
174
+
console.log('Returning cached upload details for', uploadId);
175
175
+
return cached;
176
176
+
}
177
177
+
85
178
const res = await fetch(`/.netlify/functions/get-upload-details?uploadId=${uploadId}`, {
86
179
credentials: 'include'
87
180
});
···
90
183
throw new Error('Failed to fetch upload details');
91
184
}
92
185
93
93
-
return res.json();
186
186
+
const data = await res.json();
187
187
+
188
188
+
// Cache upload details for 10 minutes
189
189
+
cache.set(cacheKey, data, 10 * 60 * 1000);
190
190
+
191
191
+
return data;
94
192
},
95
193
96
194
// Search Operations
97
195
async batchSearchActors(usernames: string[]): Promise<{ results: BatchSearchResult[] }> {
196
196
+
// Create cache key from sorted usernames (so order doesn't matter)
197
197
+
const cacheKey = `search-${usernames.slice().sort().join(',')}`;
198
198
+
const cached = cache.get<any>(cacheKey, 10 * 60 * 1000); // 10 minute cache for search results
199
199
+
if (cached) {
200
200
+
console.log('Returning cached search results for', usernames.length, 'users');
201
201
+
return cached;
202
202
+
}
203
203
+
98
204
const res = await fetch('/.netlify/functions/batch-search-actors', {
99
205
method: 'POST',
100
206
credentials: 'include',
···
106
212
throw new Error(`Batch search failed: ${res.status}`);
107
213
}
108
214
109
109
-
return res.json();
215
215
+
const data = await res.json();
216
216
+
217
217
+
// Cache search results for 10 minutes
218
218
+
cache.set(cacheKey, data, 10 * 60 * 1000);
219
219
+
220
220
+
return data;
110
221
},
111
222
112
223
// Follow Operations
···
128
239
throw new Error('Batch follow failed');
129
240
}
130
241
131
131
-
return res.json();
242
242
+
const data = await res.json();
243
243
+
244
244
+
return data;
132
245
},
133
246
134
247
// Save Results
···
161
274
if (res.ok) {
162
275
const data = await res.json();
163
276
console.log(`Successfully saved ${data.matchedUsers} matches`);
277
277
+
278
278
+
// Invalidate uploads cache after saving
279
279
+
cache.invalidate('uploads');
280
280
+
cache.set(`upload-details-${uploadId}`, { results }, 10 * 60 * 1000);
281
281
+
164
282
return data;
165
283
} else {
166
284
console.error('Failed to save results:', res.status, await res.text());
···
170
288
console.error('Error saving results (will continue in background):', error);
171
289
return null;
172
290
}
291
291
+
},
292
292
+
293
293
+
// Cache management utilities
294
294
+
cache: {
295
295
+
clear: () => cache.clear(),
296
296
+
invalidate: (key: string) => cache.invalidate(key),
297
297
+
invalidatePattern: (pattern: string) => cache.invalidatePattern(pattern),
173
298
}
174
299
};