social-reporting-tool/v2/server/auth/password-fallback.ts
DJP b89e8b511e Add V2: multi-team social-reporting platform with manifest-gated linking
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>
2026-04-29 17:39:07 -04:00

74 lines
2.8 KiB
TypeScript

// Emergency password login. OFF in prod by default. Lifted from V1 dashboard/server.ts:351-386
// but adapted to V2: still upserts a user row keyed on a deterministic synthetic oid so the
// same DASH_USER lands in the users table consistently.
import type { IncomingMessage, ServerResponse } from 'node:http';
import { envBool, envStr } from '../lib/env.js';
import { parseJSONBody, sendJSON, clientIp } from '../lib/http.js';
import { issueSession, setSessionCookie } from './session.js';
import { landSsoUser } from './upsert-user.js';
const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
const RATE_LIMIT_MAX = 5;
const attempts = new Map<string, { count: number; firstAt: number }>();
function isRateLimited(ip: string): boolean {
const r = attempts.get(ip);
if (!r) return false;
if (Date.now() - r.firstAt > RATE_LIMIT_WINDOW_MS) { attempts.delete(ip); return false; }
return r.count >= RATE_LIMIT_MAX;
}
function recordAttempt(ip: string): void {
const now = Date.now();
const r = attempts.get(ip);
if (!r || now - r.firstAt > RATE_LIMIT_WINDOW_MS) {
attempts.set(ip, { count: 1, firstAt: now });
} else {
r.count++;
}
}
export async function handlePasswordLogin(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (!envBool('ALLOW_PASSWORD_FALLBACK', false)) {
sendJSON(res, 404, { ok: false, error: 'Password login not enabled' });
return;
}
const ip = clientIp(req);
if (isRateLimited(ip)) {
sendJSON(res, 429, { ok: false, error: 'Too many attempts. Try again in 15 minutes.' });
return;
}
const DASH_USER = envStr('DASH_USER');
const DASH_PASS = envStr('DASH_PASS');
if (!DASH_USER || !DASH_PASS) {
sendJSON(res, 503, { ok: false, error: 'Password login misconfigured' });
return;
}
let username = '', password = '';
try {
const body = await parseJSONBody<{ username?: string; password?: string }>(req);
username = body.username ?? '';
password = body.password ?? '';
} catch { sendJSON(res, 400, { ok: false, error: 'Invalid body' }); return; }
if (username !== DASH_USER || password !== DASH_PASS) {
recordAttempt(ip);
sendJSON(res, 401, { ok: false, error: 'Invalid username or password' });
return;
}
attempts.delete(ip);
// Land the password user the same way SSO users land — deterministic synthetic oid.
const oid = `pw:${DASH_USER}`;
const email = `${DASH_USER}@local.password-fallback`;
const landed = await landSsoUser({ oid, email, display_name: DASH_USER });
const token = issueSession({
user_id: landed.user.id,
email: landed.user.email,
active_team_id: landed.active_team_id,
auth_method: 'password',
});
setSessionCookie(res, token);
sendJSON(res, 200, { ok: true, user: { id: landed.user.id, email: landed.user.email, name: landed.user.display_name } });
}