← back to Linkedin Agent
src/server.ts
189 lines
/**
* Express server — OAuth flow + REST API for posts/comments/reactions.
* Port: 7480 (set via PORT env, default 7480).
*
* Endpoints:
* GET / ← simple landing page (link to /auth)
* GET /auth ← redirect to LinkedIn OAuth
* GET /api/auth/linkedin/callback ← OAuth callback
* GET /api/health ← unauth heartbeat
* POST /api/post ← protected: create LinkedIn post
* POST /api/post/page ← protected: post on Company Page
* POST /api/comment ← protected: comment on a post URN
* POST /api/react ← protected: react to a post URN
*
* 2026-05-06 (tick 53): scaffold landed. Next steps: register dev app,
* wire credentials, run the OAuth flow once, test post.
*/
import express, { type Request, type Response, type NextFunction } from 'express';
import { randomBytes } from 'crypto';
import {
authorizeUrl, exchangeCode, saveToken,
postText, postOnPage, commentOnPost, react, getProfile,
} from './lib/linkedin.js';
import { composePost, readTemplate } from './lib/compose.js';
const PORT = Number(process.env.PORT || 7480);
const app = express();
app.use(express.json({ limit: '64kb' }));
// ---------------------------------------------------------------------------
// Auth gate — basic env-password header. Wave-3 pattern: AUTH_PASSWORD env.
// ---------------------------------------------------------------------------
function requireAuth(req: Request, res: Response, next: NextFunction): void {
const expected = process.env.AUTH_PASSWORD;
if (!expected) {
res.status(503).json({ error: 'AUTH_PASSWORD env unset' });
return;
}
const got = req.header('x-admin-key');
if (got !== expected) {
res.status(401).json({ error: 'Unauthorized — set X-Admin-Key header' });
return;
}
next();
}
// CSRF state for OAuth round-trip
const oauthStates = new Map<string, number>();
setInterval(() => {
const cutoff = Date.now() - 10 * 60_000;
for (const [k, v] of oauthStates) if (v < cutoff) oauthStates.delete(k);
}, 60_000);
// ---------------------------------------------------------------------------
// Landing
// ---------------------------------------------------------------------------
app.get('/', (_req, res) => {
res.set('Content-Type', 'text/html').send(`<!doctype html>
<html><head><meta charset="utf-8"><title>LinkedIn Agent</title>
<style>body{background:#07090a;color:#d8e8df;font-family:system-ui;padding:40px;max-width:680px}
a{color:#10b981}h1{font-weight:600;letter-spacing:-0.01em}code{background:#111;padding:2px 6px;border-radius:3px}</style>
</head><body>
<h1>● LinkedIn Agent</h1>
<p>Steve's autonomous LinkedIn presence. <a href="/auth">Sign in with LinkedIn →</a></p>
<p style="color:#6c7d75">After auth, Claude Code can post on your behalf via the MCP server.
See <code>docs/mcp-config.md</code>.</p>
</body></html>`);
});
// ---------------------------------------------------------------------------
// OAuth flow
// ---------------------------------------------------------------------------
app.get('/auth', (_req, res) => {
const state = randomBytes(16).toString('hex');
oauthStates.set(state, Date.now());
res.redirect(authorizeUrl(state));
});
app.get('/api/auth/linkedin/callback', async (req, res) => {
const { code, state } = req.query as { code?: string; state?: string };
if (!code || !state || !oauthStates.has(state)) {
res.status(400).send('Missing or invalid state');
return;
}
oauthStates.delete(state);
try {
const token = await exchangeCode(code);
saveToken(token);
const me = await getProfile();
res.set('Content-Type', 'text/html').send(`<!doctype html>
<html><body style="font-family:system-ui;padding:40px;background:#07090a;color:#d8e8df">
<h1 style="color:#10b981">✓ Connected</h1>
<p>Authenticated as <strong>${me.name}</strong> (${me.email || me.sub}).</p>
<p>Token saved to <code>.env.local</code>. You can close this tab.</p>
<p><a href="/">← back</a></p></body></html>`);
} catch (err) {
console.error('[oauth callback]', err);
res.status(500).send('OAuth exchange failed: ' + (err as Error).message);
}
});
// ---------------------------------------------------------------------------
// Health (unauth, opaque)
// ---------------------------------------------------------------------------
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok' });
});
// ---------------------------------------------------------------------------
// Posts / comments / reactions
// ---------------------------------------------------------------------------
app.post('/api/post', requireAuth, async (req, res) => {
const { text, includeBio } = req.body as { text?: string; includeBio?: boolean };
if (!text || text.length < 10) {
res.status(400).json({ error: 'text required (min 10 chars)' });
return;
}
try {
const final = composePost(text, { tagline: includeBio !== false });
const result = await postText(final);
res.json({ ok: true, id: result.id, posted: final });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
app.post('/api/post/page', requireAuth, async (req, res) => {
const { text } = req.body as { text?: string };
if (!text) { res.status(400).json({ error: 'text required' }); return; }
try {
const final = composePost(text, { tagline: false });
const result = await postOnPage(final);
res.json({ ok: true, id: result.id });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
app.post('/api/comment', requireAuth, async (req, res) => {
const { postUrn, text } = req.body as { postUrn?: string; text?: string };
if (!postUrn || !text) { res.status(400).json({ error: 'postUrn + text required' }); return; }
try {
const result = await commentOnPost(postUrn, text);
res.json({ ok: true, id: result.id });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
app.post('/api/react', requireAuth, async (req, res) => {
const { postUrn, reaction } = req.body as { postUrn?: string; reaction?: string };
if (!postUrn) { res.status(400).json({ error: 'postUrn required' }); return; }
try {
await react(postUrn, (reaction as 'LIKE') || 'LIKE');
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// Read a template (lets MCP / Claude pull the always-include bio, etc.)
app.get('/api/template/:name', requireAuth, (req, res) => {
const name = String(req.params.name);
const body = readTemplate(name);
if (!body) { res.status(404).json({ error: 'no template' }); return; }
res.json({ name, body });
});
// ---------------------------------------------------------------------------
// Centralized error redaction
// ---------------------------------------------------------------------------
app.use((err: Error, req: Request, res: Response, _next: NextFunction) => {
console.error(`[${req.method} ${req.path}]`, err);
if (res.headersSent) return;
res.status(500).json({ error: 'internal_error' });
});
app.listen(PORT, '127.0.0.1', () => {
console.log(`linkedin-agent listening on http://127.0.0.1:${PORT}`);
});