← 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}`);
});