Add Matches page, OkCupid integration, and major UI/feature updates

- New Matches page with match scoring system
- New OkCupid page and API integration
- Enhanced Likes page with scanner improvements and enrichment
- Updated Settings, Discover, Messages, and Chat pages
- Improved auth, GraphQL client, and Stream Chat setup
- Added new backend endpoints (matchScoring.js)
- Removed old Proxyman capture logs
- Updated nginx config and Vite proxy settings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-16 07:11:21 -05:00
parent 0a725508d2
commit f84786e654
176 changed files with 6828 additions and 1177 deletions

View File

@@ -4,6 +4,7 @@ import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { fileURLToPath } from 'url';
import { DEFAULT_WEIGHTS, scoreProfile, safeStr as scoreSafeStr } from './matchScoring.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Always use ../data (relative to server/) so Docker and local dev use the same directory
@@ -395,6 +396,19 @@ app.get('/api/discovered-profiles', (req, res) => {
}
});
// GET /api/discovered-profiles/lookup/:id - Get a single cached profile
app.get('/api/discovered-profiles/lookup/:id', (req, res) => {
const filePath = path.join(DATA_DIR, 'discoveredProfiles.json');
if (!fs.existsSync(filePath)) return res.json({ profile: null });
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
const target = (data.profiles || []).find(p => p.id === req.params.id);
res.json({ profile: target || null });
} catch (e) {
res.json({ profile: null });
}
});
// POST /api/discovered-profiles/batch - Batch upsert discovered profiles
app.post('/api/discovered-profiles/batch', (req, res) => {
const { profiles: incoming } = req.body;
@@ -421,9 +435,12 @@ app.post('/api/discovered-profiles/batch', (req, res) => {
if (!profile.id) continue;
const existing = existingMap.get(profile.id);
if (existing) {
// Update but preserve original discoveredAt
// Last-seen wins for discoveredLocation; fall back to prior value if
// this batch didn't carry one.
existingMap.set(profile.id, {
...existing,
...profile,
discoveredLocation: profile.discoveredLocation ?? existing.discoveredLocation ?? null,
discoveredAt: existing.discoveredAt,
updatedAt: new Date().toISOString(),
});
@@ -431,6 +448,7 @@ app.post('/api/discovered-profiles/batch', (req, res) => {
} else {
existingMap.set(profile.id, {
...profile,
discoveredLocation: profile.discoveredLocation ?? null,
discoveredAt: profile.discoveredAt || new Date().toISOString(),
});
added++;
@@ -479,6 +497,288 @@ app.delete('/api/discovered-profiles/:profileId', (req, res) => {
}
});
// PUT /api/discovered-profiles/update-photos - Update photos for a cached profile
app.put('/api/discovered-profiles/update-photos', (req, res) => {
const { profileId, photos } = req.body;
if (!profileId || !Array.isArray(photos)) {
return res.status(400).json({ success: false, error: 'profileId and photos array required' });
}
const filePath = path.join(DATA_DIR, 'discoveredProfiles.json');
if (fs.existsSync(filePath)) {
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
const idx = (data.profiles || []).findIndex(p => p.id === profileId);
if (idx >= 0) {
data.profiles[idx].photos = photos;
data.profiles[idx].photosRefreshedAt = new Date().toISOString();
data.updatedAt = new Date().toISOString();
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
return res.json({ success: true, updated: true });
}
return res.json({ success: true, updated: false, reason: 'profile not found' });
} catch (e) {
console.error('Failed to update photos:', e);
return res.status(500).json({ success: false, error: e.message });
}
}
res.json({ success: true, updated: false, reason: 'no cache file' });
});
// ============================================================
// Smart Matches Endpoints
// ============================================================
const MATCH_WEIGHTS_FILE = path.join(DATA_DIR, 'matchWeights.json');
function readMatchWeights() {
if (fs.existsSync(MATCH_WEIGHTS_FILE)) {
try {
return { ...DEFAULT_WEIGHTS, ...JSON.parse(fs.readFileSync(MATCH_WEIGHTS_FILE, 'utf8')) };
} catch (e) {
console.error('Failed to read matchWeights.json:', e);
}
}
return { ...DEFAULT_WEIGHTS };
}
function readJsonFile(filename, fallback) {
const filePath = path.join(DATA_DIR, filename);
if (fs.existsSync(filePath)) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (e) {
console.error(`Failed to read ${filename}:`, e);
}
}
return fallback;
}
// GET /api/matches — Score, filter, and rank discovered profiles
app.get('/api/matches', (req, res) => {
try {
const {
minAge, maxAge, maxDistance, gender, sexuality, desires,
verifiedOnly, search, theyLikedOnly, sort,
limit = '50', offset = '0',
} = req.query;
const limitNum = Math.min(parseInt(limit) || 50, 200);
const offsetNum = parseInt(offset) || 0;
// 1. Read discovered profiles
const discovered = readJsonFile('discoveredProfiles.json', { profiles: [] });
let profiles = discovered.profiles || [];
// 2. Build exclusion sets
const disliked = readJsonFile('dislikedProfiles.json', { profiles: [] });
const dislikedIds = new Set((disliked.profiles || []).map(p => p.id));
const sentPings = readJsonFile('sentPings.json', { pings: [] });
const sentPingIds = new Set((sentPings.pings || []).map(p => p.targetProfileId));
// Read user.json to get liked profiles
const userFiles = fs.readdirSync(DATA_DIR).filter(f => f.endsWith('.json') && !['discoveredProfiles', 'dislikedProfiles', 'sentPings', 'whoLikedYou', 'auth', 'auth-tokens', 'locationRotation', 'savedLocations', 'matchWeights'].some(n => f.startsWith(n)));
let likedIds = new Set();
for (const file of userFiles) {
try {
const data = JSON.parse(fs.readFileSync(path.join(DATA_DIR, file), 'utf8'));
if (data.likedProfiles) {
data.likedProfiles.forEach(p => likedIds.add(p.id));
}
} catch (e) { /* skip */ }
}
// 3. Filter out excluded profiles
profiles = profiles.filter(p => !dislikedIds.has(p.id) && !sentPingIds.has(p.id) && !likedIds.has(p.id));
// 4. Apply query param filters
if (minAge) {
const min = parseInt(minAge);
profiles = profiles.filter(p => typeof p.age === 'number' && p.age >= min);
}
if (maxAge) {
const max = parseInt(maxAge);
profiles = profiles.filter(p => typeof p.age === 'number' && p.age <= max);
}
if (maxDistance) {
const maxDist = parseInt(maxDistance);
profiles = profiles.filter(p => {
const mi = p.distance?.mi;
return typeof mi === 'number' && mi <= maxDist;
});
}
if (gender) {
const genders = gender.split(',').map(g => g.trim().toUpperCase());
profiles = profiles.filter(p => {
const pg = (typeof p.gender === 'string' ? p.gender : '').toUpperCase();
return genders.includes(pg);
});
}
if (sexuality) {
const sexualities = sexuality.split(',').map(s => s.trim().toUpperCase());
profiles = profiles.filter(p => {
const ps = (typeof p.sexuality === 'string' ? p.sexuality : '').toUpperCase();
return sexualities.includes(ps);
});
}
if (desires) {
const desireList = desires.split(',').map(d => d.trim().toUpperCase());
profiles = profiles.filter(p => {
const pd = Array.isArray(p.desires) ? p.desires.map(d => (typeof d === 'string' ? d : '').toUpperCase()) : [];
return desireList.some(d => pd.includes(d));
});
}
if (verifiedOnly === 'true') {
profiles = profiles.filter(p => {
const vs = typeof p.verificationStatus === 'string' ? p.verificationStatus : '';
return vs.toUpperCase() === 'VERIFIED';
});
}
if (theyLikedOnly === 'true') {
profiles = profiles.filter(p => p.interactionStatus?.theirs === 'LIKED');
}
// 5. Text search on name + bio
if (search) {
const searchLower = search.toLowerCase();
profiles = profiles.filter(p => {
const name = (typeof p.imaginaryName === 'string' ? p.imaginaryName : '').toLowerCase();
const bio = (typeof p.bio === 'string' ? p.bio : '').toLowerCase();
return name.includes(searchLower) || bio.includes(searchLower);
});
}
// 6. Score each profile
const weights = readMatchWeights();
const scored = profiles.map(p => {
const { total, breakdown } = scoreProfile(p, weights);
return { ...p, _score: total, _scoreBreakdown: breakdown };
});
// 7. Sort
if (sort === 'distance') {
scored.sort((a, b) => (a.distance?.mi ?? 9999) - (b.distance?.mi ?? 9999));
} else if (sort === 'recent') {
scored.sort((a, b) => {
const da = a.discoveredAt ? new Date(a.discoveredAt).getTime() : 0;
const db = b.discoveredAt ? new Date(b.discoveredAt).getTime() : 0;
return db - da;
});
} else {
// Default: score descending
scored.sort((a, b) => b._score - a._score);
}
// 8. Paginate
const total = scored.length;
const paginated = scored.slice(offsetNum, offsetNum + limitNum);
res.json({
matches: paginated,
total,
filters: { minAge, maxAge, maxDistance, gender, sexuality, desires, verifiedOnly, search, theyLikedOnly, sort },
updatedAt: discovered.updatedAt,
});
} catch (e) {
console.error('Failed to compute matches:', e);
res.status(500).json({ error: e.message });
}
});
// GET /api/matches/weights — Return current scoring weights
app.get('/api/matches/weights', (req, res) => {
res.json({ weights: readMatchWeights(), updatedAt: new Date().toISOString() });
});
// PUT /api/matches/weights — Update scoring weights
app.put('/api/matches/weights', (req, res) => {
const { weights } = req.body;
if (!weights || typeof weights !== 'object') {
return res.status(400).json({ success: false, error: 'weights object required' });
}
const merged = { ...DEFAULT_WEIGHTS, ...weights };
fs.writeFileSync(MATCH_WEIGHTS_FILE, JSON.stringify(merged, null, 2));
res.json({ success: true, weights: merged });
});
// GET /api/matches/summary — Discord-friendly text summary
app.get('/api/matches/summary', (req, res) => {
try {
const { limit = '5', ...filterParams } = req.query;
// Reuse the matches logic by building the same pipeline
const discovered = readJsonFile('discoveredProfiles.json', { profiles: [] });
let profiles = discovered.profiles || [];
// Exclusions
const disliked = readJsonFile('dislikedProfiles.json', { profiles: [] });
const dislikedIds = new Set((disliked.profiles || []).map(p => p.id));
const sentPings = readJsonFile('sentPings.json', { pings: [] });
const sentPingIds = new Set((sentPings.pings || []).map(p => p.targetProfileId));
profiles = profiles.filter(p => !dislikedIds.has(p.id) && !sentPingIds.has(p.id));
// Apply filters
if (filterParams.theyLikedOnly === 'true') {
profiles = profiles.filter(p => p.interactionStatus?.theirs === 'LIKED');
}
if (filterParams.verifiedOnly === 'true') {
profiles = profiles.filter(p => (typeof p.verificationStatus === 'string' ? p.verificationStatus : '').toUpperCase() === 'VERIFIED');
}
if (filterParams.maxAge) {
profiles = profiles.filter(p => typeof p.age === 'number' && p.age <= parseInt(filterParams.maxAge));
}
if (filterParams.minAge) {
profiles = profiles.filter(p => typeof p.age === 'number' && p.age >= parseInt(filterParams.minAge));
}
if (filterParams.maxDistance) {
profiles = profiles.filter(p => typeof p.distance?.mi === 'number' && p.distance.mi <= parseInt(filterParams.maxDistance));
}
// Score & sort
const weights = readMatchWeights();
const scored = profiles.map(p => ({
...p,
_score: scoreProfile(p, weights).total,
}));
scored.sort((a, b) => b._score - a._score);
const topN = scored.slice(0, parseInt(limit) || 5);
const total = scored.length;
// Build summary text
const lines = topN.map((p, i) => {
const name = typeof p.imaginaryName === 'string' ? p.imaginaryName : 'Unknown';
const age = p.age || '?';
const dist = p.distance?.mi != null ? `${Math.round(p.distance.mi)}mi` : '?mi';
const verified = (typeof p.verificationStatus === 'string' && p.verificationStatus.toUpperCase() === 'VERIFIED') ? 'Verified' : '';
const liked = p.interactionStatus?.theirs === 'LIKED' ? 'they liked you' : '';
const tags = [verified, liked, dist].filter(Boolean).join(', ');
return `${i + 1}. ${name}, ${age} (Score: ${p._score}) — ${tags}`;
});
const summary = topN.length > 0
? `Top ${topN.length} Matches:\n${lines.join('\n')}`
: 'No matches found with current filters.';
res.json({
summary,
matches: topN.map(p => ({
id: p.id,
imaginaryName: typeof p.imaginaryName === 'string' ? p.imaginaryName : '',
age: p.age,
score: p._score,
distance: p.distance,
verified: (typeof p.verificationStatus === 'string' && p.verificationStatus.toUpperCase() === 'VERIFIED'),
theyLikedYou: p.interactionStatus?.theirs === 'LIKED',
})),
total,
});
} catch (e) {
console.error('Failed to compute matches summary:', e);
res.status(500).json({ error: e.message });
}
});
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
@@ -522,6 +822,357 @@ app.post('/api/auth/logout', (req, res) => {
res.json({ success: true });
});
// GET /api/auth/token — Single source of truth: backend manages the refresh token,
// frontend just gets a ready-to-use access token. No more race conditions.
app.get('/api/auth/token', async (req, res) => {
try {
// Ensure feeldAPI has credentials loaded
if (!feeldAPI.refreshToken) {
feeldAPI.loadCredentials();
}
if (!feeldAPI.refreshToken) {
return res.status(503).json({ error: 'No refresh token available. Seed from mobile app first.' });
}
const accessToken = await feeldAPI.getToken();
res.json({
accessToken,
profileId: feeldAPI.profileId,
analyticsId: feeldAPI.analyticsId,
expiresAt: feeldAPI.expiresAt,
});
} catch (e) {
console.error('[Auth] Token request failed:', e.message);
res.status(500).json({ error: e.message });
}
});
// POST /api/auth/seed — Seed a refresh token (from mobile app Proxyman capture, etc.)
app.post('/api/auth/seed', (req, res) => {
const { refreshToken, profileId, analyticsId } = req.body;
if (!refreshToken) return res.status(400).json({ error: 'refreshToken required' });
feeldAPI.saveCredentials(
profileId || feeldAPI.profileId,
refreshToken,
analyticsId || feeldAPI.analyticsId
);
// Clear cached access token so next request gets a fresh one
feeldAPI.accessToken = null;
feeldAPI.expiresAt = 0;
res.json({ success: true });
});
// ============================================================
// Emulate App Open — replicate the mobile app's launch sequence
// ============================================================
app.post('/api/emulate-open', async (req, res) => {
const { latitude, longitude, locationName } = req.body;
if (latitude == null || longitude == null) {
return res.status(400).json({ error: 'latitude and longitude required' });
}
if (!feeldAPI.profileId) {
feeldAPI.loadCredentials();
if (!feeldAPI.profileId) {
return res.status(503).json({ error: 'No Feeld credentials. Seed from browser first.' });
}
}
const steps = [];
try {
// Step 1: Set device location (mimics GPS update on app open)
await feeldAPI.updateLocation(latitude, longitude);
steps.push({ step: 'DeviceLocationUpdate', status: 'ok', location: locationName || `${latitude},${longitude}` });
// Step 2: Fetch search settings
let filters = {
ageRange: [22, 59],
maxDistance: 100,
lookingFor: ['WOMAN', 'MAN_WOMAN_COUPLE', 'WOMAN_WOMAN_COUPLE'],
recentlyOnline: false,
desiringFor: [],
};
try {
const settings = await feeldAPI.getSearchSettings();
const profile = settings?.profile;
if (profile) {
filters = {
ageRange: profile.ageRange || filters.ageRange,
maxDistance: profile.distanceMax || filters.maxDistance,
lookingFor: profile.lookingFor || filters.lookingFor,
recentlyOnline: profile.recentlyOnline || false,
desiringFor: profile.desiringFor || [],
};
}
steps.push({ step: 'SearchSettings', status: 'ok', filters });
} catch (e) {
steps.push({ step: 'SearchSettings', status: 'fallback', error: e.message });
}
// Step 3: Discover profiles at this location
const discovery = await feeldAPI.discoverProfiles(filters);
const profiles = discovery?.discovery?.nodes || [];
steps.push({ step: 'DiscoverProfiles', status: 'ok', count: profiles.length, hasNextBatch: discovery?.discovery?.hasNextBatch });
// Step 4: Cache discovered profiles and detect who liked us
let newProfiles = 0;
let likedMeFound = 0;
if (profiles.length > 0) {
const discoveredFile = path.join(DATA_DIR, 'discoveredProfiles.json');
let existing = { profiles: [], updatedAt: null };
try { existing = JSON.parse(fs.readFileSync(discoveredFile, 'utf8')); } catch {}
const existingMap = new Map(existing.profiles.map(p => [p.id, p]));
for (const p of profiles) {
const safeStr = v => (typeof v === 'string' ? v : '');
const cached = {
id: p.id,
imaginaryName: safeStr(p.imaginaryName),
age: p.age,
gender: safeStr(p.gender),
sexuality: safeStr(p.sexuality),
bio: safeStr(p.bio),
desires: Array.isArray(p.desires) ? p.desires.filter(d => typeof d === 'string') : [],
connectionGoals: Array.isArray(p.connectionGoals) ? p.connectionGoals.filter(g => typeof g === 'string') : [],
verificationStatus: safeStr(p.verificationStatus),
interactionStatus: p.interactionStatus,
discoveredLocation: locationName || `${latitude},${longitude}`,
discoveredAt: existingMap.has(p.id) ? existingMap.get(p.id).discoveredAt : new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
if (!existingMap.has(p.id)) newProfiles++;
existingMap.set(p.id, cached);
// Detect who liked us
if (p.interactionStatus?.theirs === 'LIKED') {
likedMeFound++;
// Save to whoLikedYou cache
const whoFile = path.join(DATA_DIR, 'whoLikedYou.json');
let whoData = { profiles: [], updatedAt: null };
try { whoData = JSON.parse(fs.readFileSync(whoFile, 'utf8')); } catch {}
if (!whoData.profiles.some(w => w.id === p.id)) {
whoData.profiles.push({
id: p.id,
imaginaryName: safeStr(p.imaginaryName),
age: p.age,
gender: safeStr(p.gender),
sexuality: safeStr(p.sexuality),
photos: p.photos,
discoveredAt: new Date().toISOString(),
});
whoData.updatedAt = new Date().toISOString();
fs.writeFileSync(whoFile, JSON.stringify(whoData, null, 2));
}
}
}
existing.profiles = Array.from(existingMap.values());
existing.updatedAt = new Date().toISOString();
fs.writeFileSync(discoveredFile, JSON.stringify(existing, null, 2));
console.log(`Discovered profiles batch: +${newProfiles} new, ${profiles.length} updated, ${existing.profiles.length} total`);
}
res.json({
success: true,
location: locationName || `${latitude},${longitude}`,
steps,
summary: {
profilesFound: profiles.length,
newProfiles,
likedMeFound,
},
});
} catch (e) {
console.error('[EmulateOpen] Error:', e.message);
res.status(500).json({ error: e.message, steps });
}
});
// ============================================================
// OKCupid API Proxy — server-side fetch to bypass Cloudflare
// ============================================================
const OKC_TOKEN_FILE = path.join(DATA_DIR, 'okc-token.json');
const OKC_CREDS_FILE = path.join(DATA_DIR, 'okc-credentials.json');
const OKC_HEADERS = {
'Content-Type': 'application/json',
'User-Agent': 'OkCupid/111.1.0 iOS/26.2.1',
'x-okcupid-locale': 'en',
'x-okcupid-platform': 'ios',
'x-okcupid-auth-v': '1',
'x-okcupid-version': '111.1.0',
'x-okcupid-device-id': '40022B89-7089-4969-85CC-94843116EEE9',
'apollographql-client-name': 'com.okcupid.app-apollo-ios',
'apollographql-client-version': '111.1.0-1625',
'Accept': 'application/json',
};
// OKC token is valid for 45 min. We login ONCE and refresh proactively before expiry.
// NEVER login reactively in response to a failed request.
let _okcLoginInProgress = null;
async function okcLogin() {
// Deduplicate: if a login is already in progress, wait for it
if (_okcLoginInProgress) return _okcLoginInProgress;
_okcLoginInProgress = _doOkcLogin();
const result = await _okcLoginInProgress;
_okcLoginInProgress = null;
return result;
}
async function _doOkcLogin() {
if (!fs.existsSync(OKC_CREDS_FILE)) return false;
try {
const creds = JSON.parse(fs.readFileSync(OKC_CREDS_FILE, 'utf8'));
if (!creds.email || !creds.password) return false;
// Step 1: Anonymous token
const anonResp = await fetch('https://e2p-okapi.api.okcupid.com/graphql/AnonAuthToken', {
method: 'POST',
headers: OKC_HEADERS,
body: JSON.stringify({
operationName: 'AnonAuthToken',
query: 'mutation AnonAuthToken($input: AuthAnonymousInput!) { authAnonymous(input: $input) { token } }',
variables: { input: { deviceId: '40022B89-7089-4969-85CC-94843116EEE9', siteCode: 36 } },
extensions: { clientLibrary: { name: 'apollo-ios', version: '1.23.0' } },
}),
});
const anonData = await anonResp.json();
let anonToken = anonData?.data?.authAnonymous?.token;
if (!anonToken) {
console.error('[OKC] Anon auth failed:', JSON.stringify(anonData).substring(0, 200));
return false;
}
anonToken = anonToken.replace(/^Bearer\s+/i, '');
// Step 2: Login with anon token
const loginResp = await fetch('https://e2p-okapi.api.okcupid.com/graphql/AuthLogin', {
method: 'POST',
headers: { ...OKC_HEADERS, 'Authorization': 'Bearer ' + anonToken },
body: JSON.stringify({
operationName: 'AuthLogin',
query: 'mutation AuthLogin($input: AuthEmailLoginInput!) { authEmailLogin(input: $input) { token encryptedUserId status } }',
variables: { input: { email: creds.email, password: creds.password } },
extensions: { clientLibrary: { name: 'apollo-ios', version: '1.23.0' } },
}),
});
const loginData = await loginResp.json();
const newToken = loginData?.data?.authEmailLogin?.token;
if (newToken) {
const clean = newToken.replace(/^Bearer\s+/i, '');
fs.writeFileSync(OKC_TOKEN_FILE, JSON.stringify({ token: clean, updatedAt: new Date().toISOString() }, null, 2));
console.log('[OKC] Token refreshed, valid for 45 min');
return true;
}
console.error('[OKC] Login failed (status ' + loginData?.data?.authEmailLogin?.status + ')');
return false;
} catch (e) {
console.error('[OKC] Login error:', e.message);
return false;
}
}
function getOkcToken() {
if (fs.existsSync(OKC_TOKEN_FILE)) {
try {
const token = JSON.parse(fs.readFileSync(OKC_TOKEN_FILE, 'utf8')).token;
return token ? token.replace(/^Bearer\s+/i, '') : null;
} catch (e) {}
}
return null;
}
function getOkcTokenExpiry() {
const token = getOkcToken();
if (!token) return 0;
try {
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
return payload.exp * 1000;
} catch (e) { return 0; }
}
// Proactive refresh: check every 5 min, login if < 10 min remaining
setInterval(async () => {
const expiry = getOkcTokenExpiry();
if (!expiry) return;
const remaining = expiry - Date.now();
if (remaining < 10 * 60 * 1000 && remaining > -5 * 60 * 1000) {
// Between 10 min before expiry and 5 min after — refresh
console.log('[OKC] Proactive refresh: token expires in', Math.round(remaining / 60000), 'min');
await okcLogin();
}
}, 5 * 60 * 1000);
// Login on startup if token is expired or missing
(async () => {
const expiry = getOkcTokenExpiry();
if (!expiry || Date.now() > expiry) {
console.log('[OKC] Token expired or missing on startup, logging in...');
await okcLogin();
} else {
console.log('[OKC] Token valid, expires in', Math.round((expiry - Date.now()) / 60000), 'min');
}
})();
// POST /api/okcupid/graphql/:operation — Proxy GraphQL requests to OKCupid
// NO reactive login — just forward the request with current token
app.post('/api/okcupid/graphql/:operation', async (req, res) => {
const token = getOkcToken();
if (!token) {
return res.status(401).json({ error: 'No OKCupid token. Login not configured.' });
}
const operation = req.params.operation;
// Log vote operations for debugging
if (operation === 'UserVote') {
console.log('[OKC] UserVote request:', JSON.stringify(req.body?.variables || {}).substring(0, 500));
}
try {
const response = await fetch(`https://e2p-okapi.api.okcupid.com/graphql/${operation}`, {
method: 'POST',
headers: {
...OKC_HEADERS,
'Authorization': `Bearer ${token}`,
'x-match-useragent': 'OkCupid/111.1.0 iOS/26.2.1',
'X-APOLLO-OPERATION-TYPE': req.body?.query?.trim().startsWith('mutation') ? 'mutation' : 'query',
'X-APOLLO-OPERATION-NAME': operation,
'Accept-Language': 'en-US,en;q=0.9',
'Connection': 'keep-alive',
},
body: JSON.stringify({
...req.body,
extensions: { clientLibrary: { name: 'apollo-ios', version: '1.23.0' }, ...req.body.extensions },
}),
});
const data = await response.text();
if (operation === 'UserVote') {
console.log('[OKC] UserVote response:', data.substring(0, 300));
}
res.status(response.status).type('application/json').send(data);
} catch (e) {
console.error('[OKC Proxy] Error:', e.message);
res.status(500).json({ error: e.message });
}
});
// GET /api/okcupid/token — Get stored OKC token
app.get('/api/okcupid/token', (req, res) => {
const token = getOkcToken();
res.json({ token: token ? token.substring(0, 20) + '...' : null, hasToken: !!token });
});
// PUT /api/okcupid/token — Save OKC token
app.put('/api/okcupid/token', (req, res) => {
const { token } = req.body;
if (!token) return res.status(400).json({ error: 'token required' });
fs.writeFileSync(OKC_TOKEN_FILE, JSON.stringify({ token, updatedAt: new Date().toISOString() }, null, 2));
res.json({ success: true });
});
// ============================================================
// FeeldAPI Client — makes direct GraphQL calls to Feeld backend
// ============================================================
@@ -529,8 +1180,8 @@ app.post('/api/auth/logout', (req, res) => {
const FIREBASE_API_KEY = 'AIzaSyD9o9mzulN50-hqOwF6ww9pxUNUxwVOCXA';
const FIREBASE_REFRESH_URL = `https://securetoken.googleapis.com/v1/token?key=${FIREBASE_API_KEY}`;
const GRAPHQL_ENDPOINT = 'https://core.api.fldcore.com/graphql';
const APP_VERSION = '8.8.3';
const OS_VERSION = '18.6.2';
const APP_VERSION = '8.11.0';
const OS_VERSION = '26.2.1';
const AUTH_TOKENS_FILE = path.join(DATA_DIR, 'auth-tokens.json');
const ROTATION_STATE_FILE = path.join(DATA_DIR, 'locationRotation.json');
const SAVED_LOCATIONS_FILE = path.join(DATA_DIR, 'savedLocations.json');
@@ -613,7 +1264,7 @@ class FeeldAPIClient {
return this.accessToken;
}
async graphql(operationName, query, variables = {}) {
async graphql(operationName, query, variables = {}, _retried = false) {
const token = await this.getToken();
const transactionId = crypto.randomUUID();
@@ -640,6 +1291,21 @@ class FeeldAPIClient {
}
const result = await response.json();
// Auto-retry on UNAUTHENTICATED: force-refresh token and replay
if (result.errors && !_retried) {
const isAuthError = result.errors.some(e => e.extensions?.code === 'UNAUTHENTICATED');
if (isAuthError) {
console.log(`[FeeldAPI] Auth error on ${operationName}, force-refreshing token...`);
this.accessToken = null;
this.expiresAt = 0;
// Re-read credentials from disk in case another process updated them
this.loadCredentials();
await this.refreshAccessToken();
return this.graphql(operationName, query, variables, true);
}
}
if (result.errors) {
throw new Error(`GraphQL errors: ${JSON.stringify(result.errors)}`);
}
@@ -876,6 +1542,7 @@ async function performRotation() {
const existingMap = new Map(discoveredData.profiles.map(p => [p.id, p]));
for (const p of profiles) {
const sanitized = sanitizeProfile(p);
sanitized.discoveredLocation = loc.name;
const existing = existingMap.get(p.id);
if (existing) {
existingMap.set(p.id, { ...sanitized, discoveredAt: existing.discoveredAt, updatedAt: new Date().toISOString() });