import express from 'express';
import crypto from 'crypto';
const app = express();
const stateStore = new Map<string, { internalUserId: string; createdAt: number }>();
const uidMap = new Map<string, string>();
function loadConfig() {
const linkStartUrl = process.env.EMERGE_LINK_START_URL;
const querySearchUrl = process.env.EMERGE_QUERY_SEARCH_URL;
const exportStatusUrl = process.env.EMERGE_EXPORT_STATUS_URL;
const consentStatusUrl = process.env.EMERGE_CONSENT_STATUS_URL;
const signingSecret = process.env.EMERGE_SIGNING_SECRET;
const linkParamsJson = process.env.EMERGE_LINK_PARAMS_JSON;
if (!linkStartUrl || !querySearchUrl || !exportStatusUrl || !consentStatusUrl || !signingSecret || !linkParamsJson) {
throw new Error('Missing EMERGE_LINK_START_URL, EMERGE_QUERY_SEARCH_URL, EMERGE_EXPORT_STATUS_URL, EMERGE_CONSENT_STATUS_URL, EMERGE_SIGNING_SECRET, or EMERGE_LINK_PARAMS_JSON');
}
return { linkStartUrl, querySearchUrl, exportStatusUrl, consentStatusUrl, signingSecret, linkParamsJson };
}
function signParams(params: Record<string, string>, signingSecret: string): string {
const signatureBase = Object.keys(params)
.sort()
.map(key => `${key}=${params[key]}`)
.join('&');
return crypto
.createHmac('sha256', signingSecret)
.update(signatureBase)
.digest('hex');
}
function buildSignedLinkUrl(internalUserId: string): { url: string; state: string } {
const { linkStartUrl, signingSecret, linkParamsJson } = loadConfig();
const state = crypto.randomBytes(16).toString('hex');
const timestamp = new Date().toISOString();
const params: Record<string, string> = JSON.parse(linkParamsJson);
params.state = state;
params.timestamp = timestamp;
params.uid = internalUserId;
const signature = signParams(params, signingSecret);
const finalParams = new URLSearchParams(params);
finalParams.append('signature', signature);
return { url: `${linkStartUrl}?${finalParams.toString()}`, state };
}
app.get('/connect-data', async (req, res) => {
try {
const internalUserId = String(req.query.user_id || '');
if (!internalUserId) {
return res.status(400).send('Missing user_id');
}
const { url, state } = buildSignedLinkUrl(internalUserId);
stateStore.set(state, { internalUserId, createdAt: Date.now() });
return res.redirect(url);
} catch (err) {
console.error('Failed to create link', err);
return res.status(500).send('Server error');
}
});
app.get('/emerge/callback', async (req, res) => {
try {
const { status, state, uid, error_code } = req.query as Record<string, string>;
const record = stateStore.get(state);
if (!record) {
return res.status(400).send('Invalid state');
}
stateStore.delete(state);
if (status === 'success' || status === 'reauthorized') {
if (!uid) {
return res.status(400).send('Missing uid');
}
uidMap.set(record.internalUserId, uid);
return res.redirect('/dashboard?connected=true');
}
const errorMessage = encodeURIComponent(error_code || 'unknown_error');
return res.redirect(`/connect?error=${errorMessage}`);
} catch (err) {
console.error('Callback error', err);
return res.status(500).send('Server error');
}
});
function buildStatusUrl(template: string, uid: string): string {
if (template.includes('{uid}')) {
return template.replace('{uid}', encodeURIComponent(uid));
}
return template;
}
async function pollExportReady(uid: string, provider: 'google_data' | 'gmail' = 'google_data'): Promise<void> {
const { exportStatusUrl } = loadConfig();
const token = process.env.EMERGE_API_TOKEN;
if (!token) {
throw new Error('Missing EMERGE_API_TOKEN');
}
const maxAttempts = 10;
const baseDelayMs = 1500;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const url = buildStatusUrl(exportStatusUrl, uid);
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Export status failed (${response.status}): ${body}`);
}
const status = await response.json() as {
sources?: Array<{ provider: string; data_ready: boolean }>;
};
const source = status.sources?.find((item) => item.provider === provider);
if (source?.data_ready === true) {
return;
}
const delay = baseDelayMs * attempt;
await new Promise(resolve => setTimeout(resolve, delay));
}
throw new Error('Export not ready after polling');
}
app.get('/query-search', async (req, res) => {
try {
const internalUserId = String(req.query.user_id || '');
const emergeUid = uidMap.get(internalUserId);
if (!emergeUid) {
return res.status(404).send('User not connected');
}
const exportProvider = (process.env.EMERGE_EXPORT_PROVIDER as 'google_data' | 'gmail' | undefined) || 'google_data';
await pollExportReady(emergeUid, exportProvider);
const { querySearchUrl } = loadConfig();
const url = new URL(querySearchUrl);
url.searchParams.set('uid', emergeUid);
const response = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${process.env.EMERGE_API_TOKEN}` }
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Query failed (${response.status}): ${body}`);
}
const data = await response.json();
return res.json(data);
} catch (err) {
console.error('Query error', err);
return res.status(500).send('Server error');
}
});
app.get('/consent-status', async (req, res) => {
try {
const internalUserId = String(req.query.user_id || '');
const emergeUid = uidMap.get(internalUserId);
if (!emergeUid) {
return res.status(404).send('User not connected');
}
const { consentStatusUrl } = loadConfig();
const token = process.env.EMERGE_API_TOKEN;
if (!token) {
return res.status(500).send('Missing EMERGE_API_TOKEN');
}
const url = buildStatusUrl(consentStatusUrl, emergeUid);
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Consent status failed (${response.status}): ${body}`);
}
const data = await response.json();
return res.json(data);
} catch (err) {
console.error('Consent status error', err);
return res.status(500).send('Server error');
}
});
app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});