V2 lives entirely under v2/ and is built around three asks the team raised about V1: per-video assets sometimes drifted onto the wrong trend, hashtag scrapes returned junk that wasn't filterable per-client, and there was no multi-user model behind Microsoft SSO. Highlights: - Stable TikTok numeric-id key for every per-video asset; URL form drift is logged loudly to drift_log.jsonl and never silently nulls assets. Stage 5 manifest hard-gates Stage 6 if any selected video is missing any required asset; --drop-failing auto-backfills from the next-best recipe candidates. - Per-brief engagement floor (min_likes / min_plays / min_stl_pct), applied at Apify scrape time and re-validated locally; spend_log.json records raw_returned vs kept_after_floor per scrape. - Users + teams + memberships with owner/admin/editor/viewer roles; SSO upserts a user keyed on Azure oid, auto-creates a personal team, and a super-admin is bootstrapped via BOOTSTRAP_SUPER_ADMIN_EMAIL on first sign-in. Phase A integration test: 16/16 pass. - 10-stage TS pipeline (brief → seed → scrape1 → select → scrape2 → validate → analyse → insights → trends → qa → build) wired through one CLI; each stage idempotent + resumable from disk via .state sentinels. §4.5 rubrics shipped under prompts/ and loaded into Claude calls. - React 18 + Vite + TS + Tailwind operator SPA: brief intake form, team management, super-admin user list, help/FAQ ported from V1. - Separate Docker Compose project (name: social-reporting-v2, port 3457, Postgres 5437) with deploy/setup-v2.sh, deploy-v2.sh, rollback-to-v1.sh scripts that take over V1's /social-reports URL and let us roll back. Verification: 62 unit tests pass (auth/session, ids extractor with full URL fixture, engagement floor, recipes, manifest, linking-fix, MoM compare). Live smoke run on a Dove brief: 1400 raw → 253 kept (82% culled) → 21 fully-bundled videos → 25 editorial trends across 8 brief-driven categories, with drift=0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
60 lines
2.1 KiB
TypeScript
60 lines
2.1 KiB
TypeScript
// Lifted from V1 agents/social-listening/dashboard/server.ts:75-92.
|
|
// V2 session payload is richer: carries user_id + active_team_id (not just username).
|
|
import { createHmac, randomBytes } from 'node:crypto';
|
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
import { envStr, IS_PRODUCTION } from '../lib/env.js';
|
|
import { parseCookies } from '../lib/http.js';
|
|
|
|
export const SESSION_MAX_AGE_SEC = 60 * 60 * 24; // 24h
|
|
const SECRET = envStr('SESSION_SECRET') || randomBytes(32).toString('hex');
|
|
export const COOKIE_NAME = 'sl_session_v2';
|
|
|
|
export interface SessionData {
|
|
user_id: string;
|
|
email: string;
|
|
active_team_id: string | null;
|
|
auth_method: 'azure-sso' | 'password';
|
|
exp: number;
|
|
}
|
|
|
|
if (IS_PRODUCTION && !envStr('SESSION_SECRET')) {
|
|
throw new Error('SESSION_SECRET must be set in production');
|
|
}
|
|
|
|
function sign(payload: string): string {
|
|
const sig = createHmac('sha256', SECRET).update(payload).digest('hex');
|
|
return `${payload}.${sig}`;
|
|
}
|
|
|
|
export function issueSession(data: Omit<SessionData, 'exp'>): string {
|
|
const payload = JSON.stringify({ ...data, exp: Date.now() + SESSION_MAX_AGE_SEC * 1000 });
|
|
return sign(payload);
|
|
}
|
|
|
|
export function getSession(req: IncomingMessage): SessionData | null {
|
|
const token = parseCookies(req)[COOKIE_NAME];
|
|
if (!token) return null;
|
|
const dot = token.lastIndexOf('.');
|
|
if (dot === -1) return null;
|
|
const payload = token.slice(0, dot);
|
|
const sig = token.slice(dot + 1);
|
|
const expected = createHmac('sha256', SECRET).update(payload).digest('hex');
|
|
if (sig !== expected) return null;
|
|
try {
|
|
const data = JSON.parse(payload) as SessionData;
|
|
if (Date.now() > data.exp) return null;
|
|
return data;
|
|
} catch { return null; }
|
|
}
|
|
|
|
export function setSessionCookie(res: ServerResponse, token: string): void {
|
|
const secure = IS_PRODUCTION ? '; Secure' : '';
|
|
res.setHeader(
|
|
'Set-Cookie',
|
|
`${COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${SESSION_MAX_AGE_SEC}${secure}`,
|
|
);
|
|
}
|
|
|
|
export function clearSessionCookie(res: ServerResponse): void {
|
|
res.setHeader('Set-Cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; Max-Age=0`);
|
|
}
|